Merge remote-tracking branch 'origin/main' into di/new-duckdb-asset-parser

This commit is contained in:
Diego Imbert
2025-12-08 14:51:46 +01:00
175 changed files with 7418 additions and 3964 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## [1.589.3](https://github.com/windmill-labs/windmill/compare/v1.589.2...v1.589.3) (2025-12-05)
### Bug Fixes
* **app:** fix appdatetimeinput in lists ([409c342](https://github.com/windmill-labs/windmill/commit/409c342ffd4499c5ab67b3b5acca8977753ded00))
* **cli:** properly handle frontend scripts for app generate-locks ([af70eed](https://github.com/windmill-labs/windmill/commit/af70eed58d9b38f48891e14632660e3aa10ee35e))
* linked secret in resources must be of type string ([9746030](https://github.com/windmill-labs/windmill/commit/97460304e9ed54b9a04e055b884e2fb997c5ab2c))
## [1.589.2](https://github.com/windmill-labs/windmill/compare/v1.589.1...v1.589.2) (2025-12-05)
### Bug Fixes
* enable back gcp triggers in CLI ([#7299](https://github.com/windmill-labs/windmill/issues/7299)) ([3757cbc](https://github.com/windmill-labs/windmill/commit/3757cbce87a2d32539efdac7493af2efec22cb7e))
* **flow:** fix chat mode modal + toggle ([#7296](https://github.com/windmill-labs/windmill/issues/7296)) ([f0ff6f4](https://github.com/windmill-labs/windmill/commit/f0ff6f405dcffa9b70d1a14334c647c29fbf1ed4))
* **frontend:** fix saved/past inputs loading when in json mode on the flow detail page ([#7300](https://github.com/windmill-labs/windmill/issues/7300)) ([c3044a5](https://github.com/windmill-labs/windmill/commit/c3044a5a9be709807719201cde5eeeb0e70d7133))
* **frontend:** update workers page ui ([#7264](https://github.com/windmill-labs/windmill/issues/7264)) ([0594257](https://github.com/windmill-labs/windmill/commit/0594257a25d1a64e620daf4ea0b106f47424ef87))
* show related job when deployment is in progress ([#7294](https://github.com/windmill-labs/windmill/issues/7294)) ([e9f1306](https://github.com/windmill-labs/windmill/commit/e9f13065bfedd9af84da58b161349f8e79e72b2a))
## [1.589.1](https://github.com/windmill-labs/windmill/compare/v1.589.0...v1.589.1) (2025-12-03)
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, language as \"language: ScriptLang\", dedicated_worker, priority, delete_after_use, timeout, has_preprocessor, on_behalf_of_email, created_by, path from script where hash = $1 AND workspace_id = $2",
"query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, language as \"language: ScriptLang\", dedicated_worker, priority, delete_after_use, timeout, has_preprocessor, on_behalf_of_email, created_by, path from script where hash = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -45,6 +45,11 @@
},
{
"ordinal": 8,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "language: ScriptLang",
"type_info": {
"Custom": {
@@ -80,42 +85,42 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "dedicated_worker",
"type_info": "Bool"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 11,
"ordinal": 12,
"name": "delete_after_use",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 13,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 13,
"ordinal": 14,
"name": "has_preprocessor",
"type_info": "Bool"
},
{
"ordinal": 14,
"ordinal": 15,
"name": "on_behalf_of_email",
"type_info": "Text"
},
{
"ordinal": 15,
"ordinal": 16,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 16,
"ordinal": 17,
"name": "path",
"type_info": "Varchar"
}
@@ -135,6 +140,7 @@
true,
true,
true,
true,
false,
true,
true,
@@ -146,5 +152,5 @@
false
]
},
"hash": "f06ab5e0369b35694fa02c3aac685bd547a1d271eb7401df57fe1774de3211bf"
"hash": "05b69dcef0f4f649513e186e73089979c49b4b8113ee832ea7539b56a0415f32"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL\n DO UPDATE SET job_id = EXCLUDED.job_id",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "0e621bba5913482b8235d7d8442b8f0e9012c265e150afd4aa41972bf7334ba2"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT f.lock_error_logs, dm.job_id\n FROM flow f\n LEFT JOIN deployment_metadata dm ON f.versions[array_upper(f.versions, 1)] = dm.flow_version\n AND f.workspace_id = dm.workspace_id AND f.path = dm.path\n WHERE f.path = $1 AND f.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "lock_error_logs",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "1de29cdd474cbd61e15b63d111e1c42aefee683e14cc738a809ecca17370e6ee"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "23759cb515e926e272bbc8e5d8a0a9d039b99bc2026e381e99ef41cdaf6ea19f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)",
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37)",
"describe": {
"columns": [],
"parameters": {
@@ -85,10 +85,11 @@
"Bool",
"Jsonb",
"Varchar",
"Int4"
"Int4",
"Bool"
]
},
"nullable": []
},
"hash": "0a7132202ecf6c4c10340921644a90d9206c45d92a0423c0bc2396d0d66a0b0d"
"hash": "3d05d9d7e087eb6e1c14c2b8a20598581e6c7493ed99cb9ad1c2ee5d0b212d38"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.lock, s.lock_error_logs, dm.job_id\n FROM script s\n LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id\n WHERE s.hash = $1 AND s.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "lock",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "lock_error_logs",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
true,
true,
true
]
},
"hash": "513ed713afdbafb587026d1536c47a9bbaa6967e36777454746b8817d68219a5"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "53dee7c119d724624b9973ee981576154cec84a09069286d2d7144dbad54f4d6"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: JobTriggerKind\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
"query": "SELECT \n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: JobTriggerKind\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
"describe": {
"columns": [
{
@@ -193,26 +193,31 @@
},
{
"ordinal": 26,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 27,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 27,
"ordinal": 28,
"name": "preprocessed",
"type_info": "Bool"
},
{
"ordinal": 28,
"ordinal": 29,
"name": "script_entrypoint_override",
"type_info": "Varchar"
},
{
"ordinal": 29,
"ordinal": 30,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 30,
"ordinal": 31,
"name": "trigger_kind: JobTriggerKind",
"type_info": {
"Custom": {
@@ -238,12 +243,12 @@
}
},
{
"ordinal": 31,
"ordinal": 32,
"name": "visible_to_owner",
"type_info": "Bool"
},
{
"ordinal": 32,
"ordinal": 33,
"name": "permissioned_as_end_user_email",
"type_info": "Text"
}
@@ -285,9 +290,10 @@
true,
true,
true,
true,
false,
null
]
},
"hash": "3162ec92bb32af47a71cc41172cc740b5dea1304ce4dfdb4d3d0efa4266f38c5"
"hash": "6c97ab28ab47b75fb3ff39ea70fa3627f08b61bbd33aecb9ea816f8f78a04ec5"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, job_id)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL\n DO UPDATE SET job_id = EXCLUDED.job_id",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "7abde47077c38ccf005ce7180a383f97076b2cbe2f617f7af39550a0db157b2b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as, \n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: JobTriggerKind\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"query": "SELECT \n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as, \n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: JobTriggerKind\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
@@ -181,6 +181,11 @@
"ordinal": 19,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
}
],
"parameters": {
@@ -209,8 +214,9 @@
true,
true,
false,
true,
true
]
},
"hash": "6cc922a5bbd348c938a9d1431aaa0f24f078ea814b429d44403aca1e5002e750"
"hash": "7b5ad10af2a9b34fa86429499ea24c0c09c6e7e9ebfa3af90035570133f7c579"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1",
"query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
@@ -45,6 +45,11 @@
},
{
"ordinal": 8,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "language: ScriptLang",
"type_info": {
"Custom": {
@@ -80,27 +85,27 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "dedicated_worker",
"type_info": "Bool"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 11,
"ordinal": 12,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 12,
"ordinal": 13,
"name": "on_behalf_of_email",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 14,
"name": "created_by",
"type_info": "Varchar"
}
@@ -121,6 +126,7 @@
true,
true,
true,
true,
false,
true,
true,
@@ -129,5 +135,5 @@
false
]
},
"hash": "27a54f8188c25c2c089c818a991ca1c092f67227be217161d6e6617ddbf77b32"
"hash": "7f9b7ab9bec6a0f745273d0cd5602ceab46a7ec9fd225f7b9d16a2ddb9bad7b3"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT lock_error_logs FROM flow WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "lock_error_logs",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "97bf27f210572499b42ce04f19f116cc87ed06c49dcca04360250ddfd89d7ab3"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, q.workspace_id, j.runnable_id as \"runnable_id: ScriptHash\", scheduled_for, parent_job, flow_innermost_root_job, runnable_path, kind as \"kind: JobKind\", started_at, permissioned_as, created_by, script_lang as \"script_lang: ScriptLang\", \n permissioned_as_email, flow_step_id, trigger_kind as \"trigger_kind: JobTriggerKind\", trigger, q.priority, concurrent_limit, q.tag, cache_ttl, r.ping as last_ping, worker, memory_peak, running\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE j.id = $1",
"query": "SELECT id, q.workspace_id, j.runnable_id as \"runnable_id: ScriptHash\", scheduled_for, parent_job, flow_innermost_root_job, runnable_path, kind as \"kind: JobKind\", started_at, permissioned_as, created_by, script_lang as \"script_lang: ScriptLang\", \n permissioned_as_email, flow_step_id, trigger_kind as \"trigger_kind: JobTriggerKind\", trigger, q.priority, concurrent_limit, q.tag, cache_ttl, cache_ignore_s3_path, r.ping as last_ping, worker, memory_peak, running\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE j.id = $1",
"describe": {
"columns": [
{
@@ -184,21 +184,26 @@
},
{
"ordinal": 20,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 21,
"name": "last_ping",
"type_info": "Timestamptz"
},
{
"ordinal": 21,
"ordinal": 22,
"name": "worker",
"type_info": "Varchar"
},
{
"ordinal": 22,
"ordinal": 23,
"name": "memory_peak",
"type_info": "Int4"
},
{
"ordinal": 23,
"ordinal": 24,
"name": "running",
"type_info": "Bool"
}
@@ -232,8 +237,9 @@
true,
true,
true,
true,
false
]
},
"hash": "d48c9a748746080e9b6cf0366b2ca3516ac5c1d32a98946196b1a42e3f103efd"
"hash": "a84e67035584bbdb02482026b9cc0808086c50f78947d43bb88628a481f41a1d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) \n values ($1, $32, $33, $34, $35, $36, $37, $2, $41) \n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31)",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) \n values ($1, $32, $33, $34, $35, $36, $37, $2, $41) \n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42)",
"describe": {
"columns": [],
"parameters": {
@@ -124,10 +124,11 @@
}
},
"Bool",
"Varchar"
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "bf2163c542fb8c4e173167a8f333ef762fecf782424c5b61b89f32918b8d6971"
"hash": "b179a3f876ca659bed892d464bf51a733cc86a3204fcd9edccda63fddc97dced"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT lock, lock_error_logs FROM script WHERE hash = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "lock",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "lock_error_logs",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "e3ac59fcf6193007a21c808a275d3d84fd76e44f9240f4292c5c1208096b8563"
}
+40 -49
View File
@@ -3404,7 +3404,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"serde",
"sourcemap 9.2.2",
"sourcemap 9.3.0",
"swc_atoms",
"swc_common",
"swc_config",
@@ -5378,14 +5378,14 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.1.5"
version = "1.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
checksum = "a2152dbcb980c05735e2a651d96011320a949eb31a0c8b38b72645ce97dec676"
dependencies = [
"crc32fast",
"libz-rs-sys",
"libz-sys",
"miniz_oxide 0.8.9",
"zlib-rs",
]
[[package]]
@@ -7764,15 +7764,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "libz-rs-sys"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b484ba8d4f775eeca644c452a56650e544bf7e617f1d170fe7298122ead5222"
dependencies = [
"zlib-rs",
]
[[package]]
name = "libz-sys"
version = "1.1.23"
@@ -8206,9 +8197,9 @@ dependencies = [
[[package]]
name = "minicov"
version = "0.3.7"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
@@ -8253,9 +8244,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
@@ -12166,9 +12157,9 @@ dependencies = [
[[package]]
name = "sourcemap"
version = "9.2.2"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22afbcb92ce02d23815b9795523c005cb9d3c214f8b7a66318541c240ea7935"
checksum = "c8131753a3c444a6177b92fc9e1bd0d2ecaf1c8953e6a41cc599e8e7ca317ef2"
dependencies = [
"base64-simd 0.8.0",
"bitvec",
@@ -12693,7 +12684,7 @@ dependencies = [
"rustc-hash 1.1.0",
"serde",
"siphasher 0.3.11",
"sourcemap 9.2.2",
"sourcemap 9.3.0",
"swc_allocator",
"swc_atoms",
"swc_eq_ignore_macros",
@@ -12757,7 +12748,7 @@ dependencies = [
"num-bigint",
"once_cell",
"serde",
"sourcemap 9.2.2",
"sourcemap 9.3.0",
"swc_allocator",
"swc_atoms",
"swc_common",
@@ -13647,7 +13638,7 @@ dependencies = [
"bytes",
"io-uring",
"libc",
"mio 1.1.0",
"mio 1.1.1",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -15165,7 +15156,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"aws-sdk-config",
@@ -15227,7 +15218,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"argon2",
@@ -15348,7 +15339,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -15363,7 +15354,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"chrono",
"lazy_static",
@@ -15377,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"axum",
@@ -15396,7 +15387,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -15489,7 +15480,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"regex",
"serde",
@@ -15504,7 +15495,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"bytes",
@@ -15528,7 +15519,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15544,7 +15535,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15553,7 +15544,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -15565,7 +15556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"serde_json",
@@ -15577,7 +15568,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"gosyn",
@@ -15589,7 +15580,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -15601,7 +15592,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"serde_json",
@@ -15613,7 +15604,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"nu-parser",
@@ -15624,7 +15615,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15635,7 +15626,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15647,7 +15638,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -15671,7 +15662,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -15685,7 +15676,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15702,7 +15693,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -15716,7 +15707,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -15734,7 +15725,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"serde",
@@ -15745,7 +15736,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -15782,7 +15773,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15792,7 +15783,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.589.1"
version = "1.589.3"
dependencies = [
"anyhow",
"async-once-cell",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.589.1"
version = "1.589.3"
authors.workspace = true
edition.workspace = true
@@ -33,7 +33,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.589.1"
version = "1.589.3"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
5e950542fed5ce5d6154932b3bc6b2172cb32c73
9501f6dc030f4235984286cf00762ab4bb54c846
@@ -0,0 +1,2 @@
-- Remove job_id column from deployment_metadata table
ALTER TABLE deployment_metadata DROP COLUMN IF EXISTS job_id;
@@ -0,0 +1,2 @@
-- Add job_id column to deployment_metadata table to track the current deployment job
ALTER TABLE deployment_metadata ADD COLUMN IF NOT EXISTS job_id UUID;
@@ -0,0 +1,5 @@
ALTER TABLE v2_job_queue
DROP COLUMN cache_ignore_s3_path;
ALTER TABLE script
DROP COLUMN cache_ignore_s3_path;
@@ -0,0 +1,5 @@
ALTER TABLE script
ADD COLUMN cache_ignore_s3_path BOOLEAN DEFAULT NULL;
ALTER TABLE v2_job_queue
ADD COLUMN cache_ignore_s3_path BOOLEAN DEFAULT NULL;
+6 -10
View File
@@ -684,16 +684,14 @@ pub async fn run_deployed_relative_imports(
let job = RunJob::from(JobPayload::ScriptHash {
path: "f/system/test_import".to_string(),
hash: ScriptHash(script.hash),
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language,
priority: None,
apply_preprocessor: false,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
})
.push(&db2)
.await;
@@ -739,13 +737,11 @@ pub async fn run_preview_relative_imports(
path: Some("f/system/test_import".to_string()),
language,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.push(&db2)
.await;
+10 -16
View File
@@ -52,12 +52,10 @@ mod job_payload {
let result = RunJob::from(JobPayload::ScriptHash {
hash: ScriptHash(123412),
path: "f/system/hello".to_string(),
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: ScriptLang::Deno,
priority: None,
@@ -86,16 +84,14 @@ mod job_payload {
let job = RunJob::from(JobPayload::ScriptHash {
hash: ScriptHash(123413),
path: "f/system/hello_with_preprocessor".to_string(),
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: ScriptLang::Deno,
priority: None,
apply_preprocessor: true,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
})
.run_until_complete_with(db, false, port, |id| async move {
let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id)
@@ -167,10 +163,9 @@ mod job_payload {
let result = RunJob::from(JobPayload::FlowScript {
id: flow_scripts[0],
language: ScriptLang::Deno,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
path: "f/system/hello/test-0".into(),
})
@@ -187,10 +182,9 @@ mod job_payload {
let result = RunJob::from(JobPayload::FlowScript {
id: flow_scripts[1],
language: ScriptLang::Deno,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
path: "f/system/hello/test-0".into(),
})
+19 -26
View File
@@ -25,7 +25,10 @@ def main():
&db,
content,
ScriptLang::Python3,
vec!["# workspace-dependencies-mode: manual\n# py: 3.11.11","tiny==0.1.3"],
vec![
"# workspace-dependencies-mode: manual\n# py: 3.11.11",
"tiny==0.1.3",
],
)
.await?;
Ok(())
@@ -186,12 +189,10 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
@@ -237,12 +238,10 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
@@ -273,12 +272,10 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
@@ -314,12 +311,10 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
@@ -353,12 +348,10 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
+43 -63
View File
@@ -188,9 +188,8 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default()
.into(),
is_trigger: None,
assets: None,
}
@@ -202,6 +201,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -234,9 +234,8 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings:
windmill_common::jobs::ConcurrencySettings::default().into(),
is_trigger: None,
assets: None,
}
@@ -248,6 +247,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -267,6 +267,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -368,9 +369,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
is_trigger: None,
assets: None,
@@ -382,6 +381,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -425,9 +425,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
is_trigger: None,
assets: None,
}.into(),
@@ -438,6 +436,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -466,9 +465,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
is_trigger: None,
assets: None,
@@ -480,6 +477,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -499,6 +497,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -534,9 +533,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
is_trigger: None,
assets: None,
}.into(),
@@ -547,6 +544,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -864,13 +862,11 @@ func main(derp string) (string, error) {
path: None,
lock: None,
language: ScriptLang::Go,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("derp", json!("world"))
.run_until_complete(&db, false, port)
@@ -903,13 +899,11 @@ fn main(world: String) -> Result<String, String> {
path: None,
lock: None,
language: ScriptLang::Rust,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ignore_s3_path: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
cache_ttl: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
}))
.arg("world", json!("Hyrule"))
.run_until_complete(&db, false, port)
@@ -981,13 +975,11 @@ echo "hello $msg"
path: None,
lock: None,
language: ScriptLang::Bash,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("msg", json!("world"))
.run_until_complete(&db, false, port)
@@ -1016,13 +1008,11 @@ def main [ msg: string ] {
path: None,
lock: None,
language: ScriptLang::Nu,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("msg", json!("world"))
.run_until_complete(&db, false, port)
@@ -1071,13 +1061,11 @@ def main [
path: None,
lock: None,
language: ScriptLang::Nu,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("a", json!("3"))
.arg("b", json!("null"))
@@ -1135,13 +1123,11 @@ public class Main {
path: None,
lock: None,
language: ScriptLang::Java,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("a", json!(3))
.arg("b", json!(3.0))
@@ -1172,13 +1158,11 @@ export async function main(a: Date) {
path: None,
lock: None,
language: ScriptLang::Bun,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("a", json!("2024-09-24T10:00:00.000Z"))
.run_until_complete(&db, false, port)
@@ -1209,13 +1193,11 @@ export async function main(a: Date) {
path: None,
lock: None,
language: ScriptLang::Deno,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("a", json!("2024-09-24T10:00:00.000Z"))
.run_until_complete(&db, false, port)
@@ -1247,13 +1229,11 @@ def main(a: datetime, b: bytes):
path: None,
lock: None,
language: ScriptLang::Python3,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: windmill_common::jobs::ConcurrencySettings::default().into(),
debouncing_settings: windmill_common::jobs::DebouncingSettings::default(),
}))
.arg("a", json!("2024-09-24T10:00:00.000Z"))
.arg("b", json!("dGVzdA=="))
+9 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.589.1
version: 1.589.3
title: Windmill API
contact:
@@ -6005,6 +6005,9 @@ paths:
type: string
lock_error_logs:
type: string
job_id:
type: string
format: uuid
/w/{workspace}/jobs/list_selected_job_groups:
# We use post because sending a huge array as a query param can produce
@@ -6891,6 +6894,9 @@ paths:
properties:
lock_error_logs:
type: string
job_id:
type: string
format: uuid
/w/{workspace}/flows/get_triggers_count/{path}:
get:
@@ -16331,6 +16337,8 @@ components:
type: integer
cache_ttl:
type: number
cache_ignore_s3_path:
type: boolean
dedicated_worker:
type: boolean
ws_error_handler_muted:
+3 -3
View File
@@ -1770,12 +1770,12 @@ async fn execute_component(
// 1. "preview" mode.
ExecuteApp {
force_viewer_static_fields: Some(static_inputs),
force_viewer_one_of_fields: Some(one_of_inputs),
force_viewer_allow_user_resources: Some(allow_user_resources),
force_viewer_one_of_fields,
force_viewer_allow_user_resources,
..
} => (
&Policy { execution_mode: ExecutionMode::Viewer, ..Default::default() },
&PolicyTriggerableInputs { static_inputs, one_of_inputs, allow_user_resources },
&PolicyTriggerableInputs { static_inputs, one_of_inputs: force_viewer_one_of_fields.unwrap_or_default(), allow_user_resources: force_viewer_allow_user_resources.unwrap_or_default() },
),
// 2. "run" mode.
_ => {
+69 -31
View File
@@ -577,6 +577,20 @@ async fn create_flow(
.execute(&mut *new_tx)
.await?;
// Store the job_id in deployment_metadata for this flow deployment
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL
DO UPDATE SET job_id = EXCLUDED.job_id",
w_id,
nf.path,
version,
dependency_job_uuid
)
.execute(&mut *new_tx)
.await?;
new_tx.commit().await?;
webhook.send_message(
w_id.clone(),
@@ -715,13 +729,12 @@ async fn get_flow_version_by_id(
let mut tx = user_db.begin(&authed).await?;
// First, fetch the path to perform authorization check early
let path: Option<String> = sqlx::query_scalar(
"SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2",
)
.bind(version)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let path: Option<String> =
sqlx::query_scalar("SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2")
.bind(version)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let path = not_found_if_none(
path,
@@ -788,13 +801,12 @@ async fn update_flow_history(
let mut tx = user_db.begin(&authed).await?;
// Fetch path and perform authorization check early
let path: Option<String> = sqlx::query_scalar(
"SELECT path FROM flow_version WHERE workspace_id = $1 AND id = $2",
)
.bind(&w_id)
.bind(version)
.fetch_optional(&mut *tx)
.await?;
let path: Option<String> =
sqlx::query_scalar("SELECT path FROM flow_version WHERE workspace_id = $1 AND id = $2")
.bind(&w_id)
.bind(version)
.fetch_optional(&mut *tx)
.await?;
let path = not_found_if_none(
path,
@@ -1115,6 +1127,25 @@ async fn update_flow(
))
})?;
// Store the job_id in deployment_metadata for this flow deployment
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL
DO UPDATE SET job_id = EXCLUDED.job_id",
w_id,
nf.path,
version,
dependency_job_uuid
)
.execute(&mut *new_tx)
.await
.map_err(|e| {
error::Error::internal_err(format!(
"Error updating deployment_metadata with job_id: {e:#}"
))
})?;
if let Some(old_dep_job) = old_dep_job {
sqlx::query!(
"UPDATE v2_job_queue SET
@@ -1154,9 +1185,10 @@ async fn list_tokens(
list_tokens_internal(&db, &w_id, &path, true).await
}
#[derive(FromRow, Serialize)]
#[derive(Serialize)]
struct DeploymentStatus {
lock_error_logs: Option<String>,
job_id: Option<sqlx::types::Uuid>,
}
async fn get_deployment_status(
Extension(db): Extension<DB>,
@@ -1164,9 +1196,12 @@ async fn get_deployment_status(
) -> JsonResult<DeploymentStatus> {
let path = path.to_path();
let mut tx = db.begin().await?;
let status_o: Option<DeploymentStatus> = sqlx::query_as!(
DeploymentStatus,
"SELECT lock_error_logs FROM flow WHERE path = $1 AND workspace_id = $2",
let status_o = sqlx::query!(
"SELECT f.lock_error_logs, dm.job_id
FROM flow f
LEFT JOIN deployment_metadata dm ON f.versions[array_upper(f.versions, 1)] = dm.flow_version
AND f.workspace_id = dm.workspace_id AND f.path = dm.path
WHERE f.path = $1 AND f.workspace_id = $2",
path,
w_id,
)
@@ -1175,8 +1210,11 @@ async fn get_deployment_status(
let status = not_found_if_none(status_o, "DeploymentStatus", path)?;
let deployment_status =
DeploymentStatus { lock_error_logs: status.lock_error_logs, job_id: status.job_id };
tx.commit().await?;
Ok(Json(status))
Ok(Json(deployment_status))
}
async fn get_flow_by_path(
@@ -1440,10 +1478,9 @@ async fn archive_flow_by_path(
/// Validates that flow debouncing configuration is supported by all workers
/// Returns an error if debouncing is configured but workers are behind required version
async fn guard_flow_from_debounce_data(nf: &NewFlow) -> Result<()> {
if !*MIN_VERSION_SUPPORTS_DEBOUNCING.read().await && {
let flow_value = nf.parse_flow_value()?;
flow_value.debounce_key.is_some() || flow_value.debounce_delay_s.is_some()
} {
if !*MIN_VERSION_SUPPORTS_DEBOUNCING.read().await
&& !nf.parse_flow_value()?.debouncing_settings.is_default()
{
tracing::warn!(
"Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature"
);
@@ -1562,6 +1599,7 @@ mod tests {
ConstantDelay, ExponentialDelay, FlowModule, FlowModuleValue, FlowValue,
InputTransform, Retry, StopAfterIf,
},
jobs::{ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings},
scripts,
};
@@ -1594,6 +1632,7 @@ mod tests {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -1612,11 +1651,9 @@ mod tests {
path: None,
lock: None,
tag: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
assets: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
}),
stop_after_if: Some(StopAfterIf {
expr: "foo = 'bar'".to_string(),
@@ -1628,6 +1665,7 @@ mod tests {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -1660,6 +1698,7 @@ mod tests {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -1691,6 +1730,7 @@ mod tests {
retry: None,
sleep: None,
cache_ttl: None,
cache_ignore_s3_path: None,
mock: None,
timeout: None,
priority: None,
@@ -1702,17 +1742,15 @@ mod tests {
})),
preprocessor_module: None,
same_worker: false,
concurrent_limit: None,
concurrency_time_window_s: None,
skip_expr: None,
cache_ttl: None,
cache_ignore_s3_path: None,
priority: None,
early_return: None,
concurrency_key: None,
chat_input_enabled: None,
flow_env: None,
debounce_key: None,
debounce_delay_s: None,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
};
let expect = serde_json::json!({
"modules": [
+4 -1
View File
@@ -166,7 +166,7 @@ async fn create_folder(
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
let mut tx = user_db.clone().begin(&authed).await?;
if !VALID_FOLDER_NAME.is_match(&ng.name) {
return Err(windmill_common::error::Error::BadRequest(format!(
@@ -216,6 +216,9 @@ async fn create_folder(
)
.execute(&mut *tx)
.await {
drop(tx);
let mut tx = user_db.begin(&authed).await?;
let exists_for_user = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2 AND $3 = ANY(owners))",
ng.name,
+64 -51
View File
@@ -36,7 +36,8 @@ use windmill_common::flow_conversations::add_message_to_conversation_tx;
use windmill_common::flow_status::{JobResult, RestartedFrom};
use windmill_common::jobs::{
check_tag_available_for_workspace_internal, format_completed_job_result, format_result,
DynamicInput, JobTriggerKind, RunInlinePreviewScriptFnParams, ENTRYPOINT_OVERRIDE,
ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, DynamicInput,
JobTriggerKind, RunInlinePreviewScriptFnParams, ENTRYPOINT_OVERRIDE,
};
use windmill_common::s3_helpers::{upload_artifact_to_store, BundleFormat};
use windmill_common::utils::{RunnableKind, WarnAfterExt};
@@ -287,10 +288,7 @@ pub fn workspaced_service() -> Router {
"/completed/import",
post(crate::jobs_export::import_completed_jobs).layer(cors.clone()),
)
.route(
"/delete",
post(crate::jobs_export::delete_jobs),
)
.route("/delete", post(crate::jobs_export::delete_jobs))
.route(
"/completed/get/:id",
get(get_completed_job).layer(cors.clone()),
@@ -892,7 +890,7 @@ macro_rules! get_job_query {
get_job_query!(
@impl "v2_job_queue", ($($opts)*),
"scheduled_for, running, ping as last_ping, suspend, suspend_until, same_worker, pre_run_error, visible_to_owner, \
flow_innermost_root_job AS root_job, flow_leaf_jobs AS leaf_jobs, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id, cache_ttl,\
flow_innermost_root_job AS root_job, flow_leaf_jobs AS leaf_jobs, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id, cache_ttl, cache_ignore_s3_path, \
script_entrypoint_override",
"LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id",
)
@@ -1777,6 +1775,7 @@ pub struct RunJobQuery {
pub tag: Option<String>,
pub timeout: Option<i32>,
pub cache_ttl: Option<i32>,
pub cache_ignore_s3_path: Option<bool>,
pub skip_preprocessor: Option<bool>,
pub poll_delay_ms: Option<u64>,
pub memory_id: Option<Uuid>,
@@ -3542,6 +3541,7 @@ impl<'a> From<UnifiedJob> for Job {
timeout: None,
flow_step_id: None,
cache_ttl: None,
cache_ignore_s3_path: None,
priority: uj.priority,
preprocessed: uj.preprocessed,
},
@@ -4598,16 +4598,18 @@ pub async fn run_workflow_as_code(
path: job.script_path,
language: job.language.unwrap_or_else(|| ScriptLang::Deno),
lock: raw_lock,
custom_concurrency_key: windmill_queue::custom_concurrency_key(&db, &job.id)
.await
.map_err(to_anyhow)?,
concurrent_limit: job.concurrent_limit,
concurrency_time_window_s: job.concurrency_time_window_s,
concurrency_settings: windmill_common::jobs::ConcurrencySettingsWithCustom {
custom_concurrency_key: windmill_queue::custom_concurrency_key(&db, &job.id)
.await
.map_err(to_anyhow)?,
concurrent_limit: job.concurrent_limit,
concurrency_time_window_s: job.concurrency_time_window_s,
},
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
dedicated_worker: None,
// TODO(debouncing): enable for this mode
custom_debounce_key: None,
debounce_delay_s: None,
debouncing_settings: DebouncingSettings::default(),
}),
Some(job.tag.clone()),
None,
@@ -5426,6 +5428,7 @@ pub async fn run_wait_result_script_by_hash(
debounce_key,
debounce_delay_s,
mut cache_ttl,
mut cache_ignore_s3_path,
language,
dedicated_worker,
priority,
@@ -5438,6 +5441,7 @@ pub async fn run_wait_result_script_by_hash(
} = get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash).await?;
if let Some(run_query_cache_ttl) = run_query.cache_ttl {
cache_ttl = Some(run_query_cache_ttl);
cache_ignore_s3_path = run_query.cache_ignore_s3_path;
}
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
@@ -5468,12 +5472,19 @@ pub async fn run_wait_result_script_by_hash(
JobPayload::ScriptHash {
hash: ScriptHash(hash),
path: path,
custom_concurrency_key: concurrency_key,
concurrent_limit: concurrent_limit,
concurrency_time_window_s: concurrency_time_window_s,
custom_debounce_key: debounce_key,
debounce_delay_s,
concurrency_settings: windmill_common::jobs::ConcurrencySettingsWithCustom {
custom_concurrency_key: concurrency_key,
concurrent_limit: concurrent_limit,
concurrency_time_window_s: concurrency_time_window_s,
}
.into(),
debouncing_settings: DebouncingSettings {
custom_key: debounce_key,
delay_s: debounce_delay_s,
..Default::default() // TODO
},
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
@@ -5944,12 +5955,10 @@ async fn run_preview_script(
path: preview.path,
language: preview.language.unwrap_or(ScriptLang::Deno),
lock: preview.lock,
custom_concurrency_key: None,
concurrent_limit: None, // TODO(gbouv): once I find out how to store limits in the content of a script, should be easy to plug limits here
concurrency_time_window_s: None, // TODO(gbouv): same as above
custom_debounce_key: None, // TODO(pyra): same as for concurrency limits.
debounce_delay_s: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(), // TODO(gbouv): once I find out how to store limits in the content of a script, should be easy to plug limits here
debouncing_settings: DebouncingSettings::default(), // TODO(pyra): same as for concurrency limits.
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: preview.dedicated_worker,
}),
},
@@ -6099,13 +6108,11 @@ async fn run_bundle_preview_script(
path: preview.path,
language: preview.language.unwrap_or(ScriptLang::Deno),
lock: preview.lock,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: preview.dedicated_worker,
custom_concurrency_key: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
debouncing_settings: DebouncingSettings::default(),
}),
PushArgs::from(&args),
authed.display_username(),
@@ -6509,19 +6516,19 @@ async fn add_batch_jobs(
add_virtual_items_if_necessary(&mut value.modules);
let flow_status = FlowStatus::new(&value);
(
None, // script_hash
path, // script_path
job_kind, // job_kind
None, // language
None, // dedicated_worker
value.concurrency_key.clone(), // custom_concurrency_key
value.concurrent_limit.clone(), // concurrent_limit
value.concurrency_time_window_s, // concurrency_time_window_s
None, // timeout
None, // raw_code
None, // raw_lock
Some(value), // raw_flow
Some(flow_status), // flow_status
None, // script_hash
path, // script_path
job_kind, // job_kind
None, // language
None, // dedicated_worker
value.concurrency_settings.concurrency_key.clone(), // custom_concurrency_key
value.concurrency_settings.concurrent_limit.clone(), // concurrent_limit
value.concurrency_settings.concurrency_time_window_s, // concurrency_time_window_s
None, // timeout
None, // raw_code
None, // raw_lock
Some(value), // raw_flow
Some(flow_status), // flow_status
)
}
"noop" => (
@@ -6890,13 +6897,11 @@ async fn run_dynamic_select(
path: None,
language: dynamic_input.x_windmill_dyn_select_lang,
lock: None,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: ConcurrencySettings::default().into(),
debouncing_settings: DebouncingSettings::default(),
}),
PushArgs::from(&request.args.unwrap_or_default()),
authed.display_username(),
@@ -6976,6 +6981,7 @@ pub async fn run_job_by_hash_inner(
debounce_delay_s,
debounce_key,
mut cache_ttl,
mut cache_ignore_s3_path,
language,
dedicated_worker,
priority,
@@ -6990,6 +6996,7 @@ pub async fn run_job_by_hash_inner(
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
if let Some(run_query_cache_ttl) = run_query.cache_ttl {
cache_ttl = Some(run_query_cache_ttl);
cache_ignore_s3_path = run_query.cache_ignore_s3_path;
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(tag);
@@ -7020,12 +7027,18 @@ pub async fn run_job_by_hash_inner(
JobPayload::ScriptHash {
hash: ScriptHash(hash),
path: path,
custom_concurrency_key: concurrency_key,
concurrent_limit: concurrent_limit,
concurrency_time_window_s: concurrency_time_window_s,
custom_debounce_key: debounce_key,
debounce_delay_s,
concurrency_settings: ConcurrencySettings {
concurrency_key,
concurrent_limit,
concurrency_time_window_s,
},
debouncing_settings: DebouncingSettings {
custom_key: debounce_key,
delay_s: debounce_delay_s,
..Default::default()
},
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
+37 -10
View File
@@ -94,6 +94,8 @@ pub struct ScriptWDraft {
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
@@ -801,8 +803,8 @@ async fn create_script_internal<'c>(
content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)",
delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37)",
&w_id,
&hash.0,
ns.path,
@@ -843,6 +845,7 @@ async fn create_script_internal<'c>(
ns.assets.as_ref().and_then(|a| serde_json::to_value(a).ok()),
ns.debounce_key,
ns.debounce_delay_s,
ns.cache_ignore_s3_path,
)
.execute(&mut *tx)
.await?;
@@ -998,14 +1001,14 @@ async fn create_script_internal<'c>(
}
let tx = PushIsolationLevel::Transaction(tx);
let (_, new_tx) = windmill_queue::push(
let (job_id, mut new_tx) = windmill_queue::push(
&db,
tx,
&w_id,
JobPayload::Dependencies {
hash,
language: ns.language,
path: ns.path,
path: ns.path.clone(),
dedicated_worker: ns.dedicated_worker,
},
windmill_queue::PushArgs::from(&args),
@@ -1034,6 +1037,21 @@ async fn create_script_internal<'c>(
None,
)
.await?;
// Store the job_id in deployment_metadata for this script deployment
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, script_hash, job_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL
DO UPDATE SET job_id = EXCLUDED.job_id",
w_id,
ns.path,
hash.0,
job_id
)
.execute(&mut *new_tx)
.await?;
Ok((hash, new_tx, None))
} else {
if codebase.is_none() {
@@ -1223,7 +1241,7 @@ async fn get_script_by_path_w_draft(
let mut tx = user_db.begin(&authed).await?;
let script_o = sqlx::query_as::<_, ScriptWDraft>(
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, has_preprocessor, on_behalf_of_email, assets, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, has_preprocessor, on_behalf_of_email, assets, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
WHERE script.path = $1 AND script.workspace_id = $2
ORDER BY script.created_at DESC LIMIT 1",
@@ -1750,19 +1768,22 @@ async fn raw_script_by_hash(
Ok(r.script.content)
}
#[derive(FromRow, Serialize)]
#[derive(Serialize)]
struct DeploymentStatus {
lock: Option<String>,
lock_error_logs: Option<String>,
job_id: Option<sqlx::types::Uuid>,
}
async fn get_deployment_status(
Extension(db): Extension<DB>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<DeploymentStatus> {
let mut tx = db.begin().await?;
let status_o: Option<DeploymentStatus> = sqlx::query_as!(
DeploymentStatus,
"SELECT lock, lock_error_logs FROM script WHERE hash = $1 AND workspace_id = $2",
let status_o = sqlx::query!(
"SELECT s.lock, s.lock_error_logs, dm.job_id
FROM script s
LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id
WHERE s.hash = $1 AND s.workspace_id = $2",
hash.0,
w_id,
)
@@ -1771,8 +1792,14 @@ async fn get_deployment_status(
let status = not_found_if_none(status_o, "DeploymentStatus", hash.to_string())?;
let deployment_status = DeploymentStatus {
lock: status.lock,
lock_error_logs: status.lock_error_logs,
job_id: status.job_id,
};
tx.commit().await?;
Ok(Json(status))
Ok(Json(deployment_status))
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
@@ -844,12 +844,10 @@ async fn trigger_script_with_retry_and_error_handler(
JobPayload::ScriptHash {
hash,
path,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
custom_debounce_key,
debounce_delay_s,
concurrency_settings,
debouncing_settings,
cache_ttl,
cache_ignore_s3_path,
priority,
apply_preprocessor,
..
@@ -862,16 +860,14 @@ async fn trigger_script_with_retry_and_error_handler(
error_handler_path,
error_handler_args,
skip_handler: None,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
cache_ttl,
cache_ignore_s3_path,
priority,
tag_override: tag.clone(),
apply_preprocessor,
trigger_path: Some(trigger_path),
custom_debounce_key,
debounce_delay_s,
concurrency_settings,
debouncing_settings,
},
_ => {
return Err(windmill_common::error::Error::internal_err(format!(
+4 -4
View File
@@ -1216,7 +1216,7 @@ async fn edit_ducklake_config(
)
.await?;
// Check that non-superadmins are not abusing Instance catalogs
// Check that non-superadmins are not abusing Instance databases
if !is_superadmin {
let old_ducklakes = sqlx::query_scalar!(
r#"
@@ -1240,7 +1240,7 @@ async fn edit_ducklake_config(
|| old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path
{
return Err(Error::BadRequest(
"Only superadmins can create or modify ducklakes with Instance catalogs"
"Only superadmins can create or modify ducklakes with Instance databases"
.to_string(),
));
}
@@ -1288,7 +1288,7 @@ async fn edit_datatable_config(
)
.await?;
// Check that non-superadmins are not abusing Instance catalogs
// Check that non-superadmins are not abusing Instance databases
if !is_superadmin {
let old_datatables = sqlx::query_scalar!(
r#"
@@ -1312,7 +1312,7 @@ async fn edit_datatable_config(
|| old_dt.unwrap().database.resource_path != dt.database.resource_path
{
return Err(Error::BadRequest(
"Only superadmins can create or modify data tables with Instance catalogs"
"Only superadmins can create or modify data tables with Instance databases"
.to_string(),
));
}
+20 -39
View File
@@ -24,6 +24,7 @@ use crate::{
cache,
db::DB,
error::{Error, Result as WindmillResult},
jobs::{ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings},
more_serde::{default_empty_string, default_id, default_null, default_true, is_default},
scripts::{Schema, ScriptHash, ScriptLang},
worker::{to_raw_value, Connection},
@@ -171,23 +172,17 @@ pub struct FlowValue {
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
pub same_worker: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrent_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency_time_window_s: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debounce_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debounce_delay_s: Option<i32>,
#[serde(flatten)]
pub concurrency_settings: ConcurrencySettings,
#[serde(flatten)]
pub debouncing_settings: DebouncingSettings,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_expr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub early_return: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
// Priority at the flow level
@@ -449,6 +444,8 @@ pub struct FlowModule {
pub sleep: Option<InputTransform>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(
default,
deserialize_with = "raw_value_to_input_transform::<_, i32>",
@@ -919,12 +916,8 @@ pub enum FlowModuleValue {
#[serde(skip_serializing_if = "is_none_or_empty")]
tag: Option<String>,
language: ScriptLang,
#[serde(skip_serializing_if = "Option::is_none")]
custom_concurrency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
concurrent_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
concurrency_time_window_s: Option<i32>,
#[serde(flatten)]
concurrency_settings: ConcurrencySettingsWithCustom,
#[serde(skip_serializing_if = "Option::is_none")]
is_trigger: Option<bool>,
#[serde(skip_serializing_if = "is_none_or_empty_vec")]
@@ -945,12 +938,8 @@ pub enum FlowModuleValue {
#[serde(skip_serializing_if = "is_none_or_empty")]
tag: Option<String>,
language: ScriptLang,
#[serde(skip_serializing_if = "Option::is_none")]
custom_concurrency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
concurrent_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
concurrency_time_window_s: Option<i32>,
#[serde(flatten)]
concurrency_settings: ConcurrencySettingsWithCustom,
#[serde(skip_serializing_if = "Option::is_none")]
is_trigger: Option<bool>,
#[serde(skip_serializing_if = "is_none_or_empty_vec")]
@@ -989,9 +978,6 @@ struct UntaggedFlowModuleValue {
lock: Option<String>,
tag: Option<String>,
language: Option<ScriptLang>,
custom_concurrency_key: Option<String>,
concurrent_limit: Option<i32>,
concurrency_time_window_s: Option<i32>,
is_trigger: Option<bool>,
id: Option<FlowNodeId>,
default_node: Option<FlowNodeId>,
@@ -1000,6 +986,8 @@ struct UntaggedFlowModuleValue {
tools: Option<Vec<AgentTool>>,
pass_flow_input_directly: Option<bool>,
squash: Option<bool>,
#[serde(flatten)]
concurrency_settings: ConcurrencySettingsWithCustom,
}
impl<'de> Deserialize<'de> for FlowModuleValue {
@@ -1074,9 +1062,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
language: untagged
.language
.ok_or_else(|| serde::de::Error::missing_field("language"))?,
custom_concurrency_key: untagged.custom_concurrency_key,
concurrent_limit: untagged.concurrent_limit,
concurrency_time_window_s: untagged.concurrency_time_window_s,
concurrency_settings: untagged.concurrency_settings,
is_trigger: untagged.is_trigger,
assets: untagged.assets,
}),
@@ -1089,9 +1075,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
language: untagged
.language
.ok_or_else(|| serde::de::Error::missing_field("language"))?,
custom_concurrency_key: untagged.custom_concurrency_key,
concurrent_limit: untagged.concurrent_limit,
concurrency_time_window_s: untagged.concurrency_time_window_s,
concurrency_settings: untagged.concurrency_settings,
is_trigger: untagged.is_trigger,
assets: untagged.assets,
}),
@@ -1164,6 +1148,7 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec<FlowModule>) {
sleep: None,
suspend: None,
cache_ttl: None,
cache_ignore_s3_path: None,
timeout: None,
priority: None,
delete_after_use: None,
@@ -1234,11 +1219,9 @@ pub async fn resolve_module(
id,
tag,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
assets,
concurrency_settings,
} = std::mem::replace(&mut val, Identity)
else {
unreachable!()
@@ -1258,11 +1241,9 @@ pub async fn resolve_module(
path: None,
tag,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
assets,
concurrency_settings,
};
}
ForloopFlow { modules, modules_node, .. } | WhileloopFlow { modules, modules_node, .. } => {
+168 -35
View File
@@ -75,7 +75,7 @@ impl std::fmt::Display for JobTriggerKind {
}
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Copy, Clone)]
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Default)]
#[sqlx(type_name = "JOB_KIND", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum JobKind {
@@ -90,6 +90,7 @@ pub enum JobKind {
Identity,
FlowDependencies,
AppDependencies,
#[default]
Noop,
DeploymentCallback,
FlowScript,
@@ -187,6 +188,8 @@ pub struct QueuedJob {
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preprocessed: Option<bool>,
@@ -260,6 +263,7 @@ impl Default for QueuedJob {
timeout: None,
flow_step_id: None,
cache_ttl: None,
cache_ignore_s3_path: None,
priority: None,
preprocessed: None,
}
@@ -346,23 +350,14 @@ pub enum JobPayload {
ScriptHash {
hash: ScriptHash,
path: String,
/// Override default concurrency key
custom_concurrency_key: Option<String>,
/// How many jobs can run at the same time
concurrent_limit: Option<i32>,
/// In seconds
concurrency_time_window_s: Option<i32>,
/// If not set, will be inferred from the hash(path + step_id + inputs)
custom_debounce_key: Option<String>,
/// Debouncing delay will be determined by the first job with the key.
/// All subsequent jobs with Some will get debounced.
/// If the job has no delay, it will execute immediately, fully ignoring pending delays.
debounce_delay_s: Option<i32>,
cache_ttl: Option<i32>,
cache_ignore_s3_path: Option<bool>,
dedicated_worker: Option<bool>,
language: ScriptLang,
priority: Option<i16>,
apply_preprocessor: bool,
concurrency_settings: ConcurrencySettings,
debouncing_settings: DebouncingSettings,
},
/// Execute flow step (can be subflow only).
@@ -374,13 +369,12 @@ pub enum JobPayload {
/// Execute flow step
FlowScript {
id: FlowNodeId, // flow_node(id).
language: ScriptLang,
custom_concurrency_key: Option<String>,
concurrent_limit: Option<i32>,
concurrency_time_window_s: Option<i32>,
cache_ttl: Option<i32>,
dedicated_worker: Option<bool>,
path: String,
language: ScriptLang,
cache_ttl: Option<i32>,
cache_ignore_s3_path: Option<bool>,
dedicated_worker: Option<bool>,
concurrency_settings: ConcurrencySettings,
},
/// Inline App Script
@@ -460,19 +454,18 @@ pub enum JobPayload {
error_handler_path: Option<String>,
error_handler_args: Option<HashMap<String, Box<RawValue>>>,
skip_handler: Option<SkipHandler>,
custom_concurrency_key: Option<String>,
concurrent_limit: Option<i32>,
concurrency_time_window_s: Option<i32>,
custom_debounce_key: Option<String>,
debounce_delay_s: Option<i32>,
cache_ttl: Option<i32>,
cache_ignore_s3_path: Option<bool>,
priority: Option<i16>,
tag_override: Option<String>,
trigger_path: Option<String>,
apply_preprocessor: bool,
concurrency_settings: ConcurrencySettings,
debouncing_settings: DebouncingSettings,
},
DeploymentCallback {
path: String,
// debouncing_settings: Option<DebouncingSettings>,
},
Identity,
Noop,
@@ -481,6 +474,108 @@ pub enum JobPayload {
},
}
// TODO: Add validation logic.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct DebouncingSettings {
#[serde(
skip_serializing_if = "Option::is_none",
rename = "debounce_key",
alias = "custom_debounce_key"
)]
/// debounce key is usually stored in the db
/// including when:
///
/// 1. User have created custom debounce key from ui or cli
/// 2. User used default one
///
/// in either cases this argument serves as reactive way of overwriting debounce key from the backend.
/// Default: hash(path + step_id + inputs)
pub custom_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "debounce_delay_s")]
/// Debouncing delay will be determined by the first job with the key.
/// All subsequent jobs with Some will get debounced.
/// If the job has no delay, it will execute immediately, fully ignoring pending delays.
pub delay_s: Option<i32>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "max_total_debouncing_time"
)]
pub max_total_time: Option<i32>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "max_total_debounces_amount"
)]
pub max_total_amount: Option<i32>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "debounce_args_to_accumulate"
)]
/// top level arguments to preserve
/// For every debounce selected arguments will be saved
/// in the end (when job finally starts) arguments will be appended and passed to runnable
///
/// NOTE: selected args should be the lists.
pub args_to_accumulate: Option<Vec<String>>,
}
impl DebouncingSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ConcurrencySettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrent_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency_time_window_s: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, Default)]
pub struct ConcurrencySettingsWithCustom {
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_concurrency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrent_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency_time_window_s: Option<i32>,
}
impl From<ConcurrencySettings> for ConcurrencySettingsWithCustom {
fn from(
ConcurrencySettings { concurrency_key, concurrent_limit, concurrency_time_window_s }: ConcurrencySettings,
) -> Self {
ConcurrencySettingsWithCustom {
custom_concurrency_key: concurrency_key,
concurrency_time_window_s,
concurrent_limit,
}
}
}
impl From<ConcurrencySettingsWithCustom> for ConcurrencySettings {
fn from(
ConcurrencySettingsWithCustom {
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
}: ConcurrencySettingsWithCustom,
) -> Self {
ConcurrencySettings {
concurrency_key: custom_concurrency_key,
concurrency_time_window_s,
concurrent_limit,
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct SkipHandler {
pub path: String,
@@ -489,20 +584,50 @@ pub struct SkipHandler {
pub stop_message: String,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[derive(Clone, Deserialize, Debug, Default)]
pub struct RawCode {
pub content: String,
pub path: Option<String>,
pub hash: Option<i64>,
pub language: ScriptLang,
pub lock: Option<String>,
pub custom_concurrency_key: Option<String>,
pub concurrent_limit: Option<i32>,
pub concurrency_time_window_s: Option<i32>,
pub custom_debounce_key: Option<String>,
pub debounce_delay_s: Option<i32>,
pub cache_ttl: Option<i32>,
pub cache_ignore_s3_path: Option<bool>,
pub dedicated_worker: Option<bool>,
#[serde(flatten)]
pub concurrency_settings: ConcurrencySettingsWithCustom,
#[serde(flatten)]
// NOTE: Since we can only deserialize the struct,
// even though the older versions pass `custom_debounce_key` to RawCode,
// we can still have `debounce_key` in DebouncingSettings
// we just add alias `custom_debounce_key`
// however, serializing this settings will produce `debounce_key`
pub debouncing_settings: DebouncingSettings,
}
impl JobPayload {
pub fn job_kind(&self) -> JobKind {
match self {
JobPayload::Noop => JobKind::Noop,
JobPayload::Identity => JobKind::Identity,
JobPayload::Code { .. } => JobKind::Preview,
JobPayload::AIAgent { .. } => JobKind::AIAgent,
JobPayload::FlowNode { .. } => JobKind::FlowNode,
JobPayload::ScriptHash { .. } => JobKind::Script,
JobPayload::AppScript { .. } => JobKind::AppScript,
JobPayload::RawFlow { .. } => JobKind::FlowPreview,
JobPayload::ScriptHub { .. } => JobKind::Script_Hub,
JobPayload::FlowScript { .. } => JobKind::FlowScript,
JobPayload::Dependencies { .. } => JobKind::Dependencies,
JobPayload::SingleStepFlow { .. } => JobKind::SingleStepFlow,
JobPayload::AppDependencies { .. } => JobKind::AppDependencies,
JobPayload::FlowDependencies { .. } => JobKind::FlowDependencies,
JobPayload::RawScriptDependencies { .. } => JobKind::Dependencies,
JobPayload::RawFlowDependencies { .. } => JobKind::FlowDependencies,
JobPayload::DeploymentCallback { .. } => JobKind::DeploymentCallback,
JobPayload::Flow { .. } | JobPayload::RestartedFlow { .. } => JobKind::Flow,
}
}
}
type Tag = String;
@@ -575,6 +700,7 @@ pub async fn script_path_to_payload<'e>(
debounce_key,
debounce_delay_s,
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
@@ -599,17 +725,24 @@ pub async fn script_path_to_payload<'e>(
JobPayload::ScriptHash {
hash: ScriptHash(hash),
path: script_path.to_owned(),
custom_concurrency_key: concurrency_key,
concurrent_limit,
concurrency_time_window_s,
custom_debounce_key: debounce_key,
debounce_delay_s,
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
apply_preprocessor: !skip_preprocessor.unwrap_or(false)
&& has_preprocessor.unwrap_or(false),
concurrency_settings: ConcurrencySettingsWithCustom {
custom_concurrency_key: concurrency_key,
concurrent_limit,
concurrency_time_window_s,
}
.into(),
debouncing_settings: DebouncingSettings {
custom_key: debounce_key,
delay_s: debounce_delay_s,
..Default::default()
},
},
tag,
delete_after_use,
+5 -2
View File
@@ -659,6 +659,7 @@ pub struct ScriptHashInfo {
pub debounce_key: Option<String>,
pub debounce_delay_s: Option<i32>,
pub cache_ttl: Option<i32>,
pub cache_ignore_s3_path: Option<bool>,
pub language: ScriptLang,
pub dedicated_worker: Option<bool>,
pub priority: Option<i16>,
@@ -804,7 +805,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>(
) -> error::Result<Option<ScriptHashInfo>> {
let r = sqlx::query_as!(
ScriptHashInfo,
"select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, language as \"language: ScriptLang\", dedicated_worker, priority, delete_after_use, timeout, has_preprocessor, on_behalf_of_email, created_by, path from script where hash = $1 AND workspace_id = $2",
"select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, language as \"language: ScriptLang\", dedicated_worker, priority, delete_after_use, timeout, has_preprocessor, on_behalf_of_email, created_by, path from script where hash = $1 AND workspace_id = $2",
hash,
w_id
)
@@ -1022,6 +1023,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>(
Option<String>,
Option<i32>,
Option<i32>,
Option<bool>,
ScriptLang,
Option<bool>,
Option<i16>,
@@ -1030,7 +1032,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>(
String,
)> {
let r_o = sqlx::query!(
"select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by FROM script
"select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by FROM script
WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)
ORDER BY created_at DESC LIMIT 1",
script_path,
@@ -1051,6 +1053,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>(
script.debounce_key,
script.debounce_delay_s,
script.cache_ttl,
script.cache_ignore_s3_path,
script.language,
script.dedicated_worker,
script.priority,
+11 -2
View File
@@ -393,6 +393,8 @@ pub struct Script {
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_after_use: Option<bool>,
@@ -495,6 +497,10 @@ pub struct NewScript {
pub tag: Option<String>,
pub draft_only: Option<bool>,
pub envs: Option<Vec<String>>,
// NOTE: concurrency and debounce data is inline,
// bc it was this before refactor
// and rust seems to hash it differently
// for backwards compat we keep them inline
pub concurrency_key: Option<String>,
pub concurrent_limit: Option<i32>,
pub concurrency_time_window_s: Option<i32>,
@@ -503,6 +509,7 @@ pub struct NewScript {
#[serde(skip_serializing_if = "Option::is_none")]
pub debounce_delay_s: Option<i32>,
pub cache_ttl: Option<i32>,
pub cache_ignore_s3_path: Option<bool>,
pub dedicated_worker: Option<bool>,
pub ws_error_handler_muted: Option<bool>,
pub priority: Option<i16>,
@@ -853,6 +860,7 @@ pub async fn fetch_script_for_update<'a>(
ws_error_handler_muted,
priority,
cache_ttl,
cache_ignore_s3_path,
timeout,
delete_after_use,
restart_unless_cancelled,
@@ -910,6 +918,7 @@ pub async fn clone_script<'c>(
concurrent_limit: s.concurrent_limit,
concurrency_time_window_s: s.concurrency_time_window_s,
cache_ttl: s.cache_ttl,
cache_ignore_s3_path: s.cache_ignore_s3_path,
dedicated_worker: s.dedicated_worker,
ws_error_handler_muted: s.ws_error_handler_muted,
priority: s.priority,
@@ -941,14 +950,14 @@ pub async fn clone_script<'c>(
INSERT INTO script
(workspace_id, hash, path, parent_hashes, summary, description, content, \
created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, \
codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s)
SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \
content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, \
codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s
+2 -2
View File
@@ -411,7 +411,7 @@ fn format_pull_query(peek: String) -> String {
WHERE id = (SELECT id FROM peek)
RETURNING
started_at, scheduled_for,
canceled_by, canceled_reason, worker
canceled_by, canceled_reason, worker, cache_ignore_s3_path
), r AS NOT MATERIALIZED (
UPDATE v2_job_runtime SET
ping = now()
@@ -437,7 +437,7 @@ fn format_pull_query(peek: String) -> String {
flow_status, j.script_lang,
j.same_worker, j.pre_run_error, j.visible_to_owner,
j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job,
j.timeout, j.flow_step_id, j.cache_ttl, j.priority, j.raw_code, j.raw_lock, j.raw_flow,
j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, j.priority, j.raw_code, j.raw_lock, j.raw_flow,
j.script_entrypoint_override, j.preprocessed, pj.runnable_path as parent_runnable_path,
COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin,
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email
File diff suppressed because it is too large Load Diff
+21 -15
View File
@@ -20,6 +20,8 @@ use windmill_common::flows::Retry;
use windmill_common::get_flow_version_info_from_version;
use windmill_common::get_latest_flow_version_id_for_path;
use windmill_common::jobs::check_tag_available_for_workspace_internal;
use windmill_common::jobs::ConcurrencySettings;
use windmill_common::jobs::DebouncingSettings;
use windmill_common::jobs::JobPayload;
use windmill_common::schedule::schedule_to_user;
use windmill_common::scripts::ScriptHash;
@@ -90,6 +92,7 @@ async fn get_schedule_metadata<'c>(
_debounce_key,
_debounce_delay_s,
_cache_ttl,
_cache_ignore_s3_path,
_language,
_dedicated_worker,
_priority,
@@ -258,16 +261,14 @@ pub async fn push_scheduled_job<'c>(
stop_condition,
stop_message,
}),
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
cache_ignore_s3_path: None,
priority: None,
tag_override: schedule.tag.clone(),
trigger_path: None,
apply_preprocessor: false,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
},
if schedule.tag.as_ref().is_some_and(|x| x != "") {
schedule.tag.clone()
@@ -322,6 +323,7 @@ pub async fn push_scheduled_job<'c>(
custom_debounce_key,
debounce_delay_s,
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
@@ -360,16 +362,14 @@ pub async fn push_scheduled_job<'c>(
error_handler_args: None,
skip_handler: None,
args: static_args,
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl,
cache_ignore_s3_path,
priority,
tag_override: schedule.tag.clone(),
trigger_path: None,
apply_preprocessor: false,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
},
if schedule.tag.as_ref().is_some_and(|x| x != "") {
schedule.tag.clone()
@@ -385,16 +385,22 @@ pub async fn push_scheduled_job<'c>(
JobPayload::ScriptHash {
hash,
path: schedule.script_path.clone(),
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
cache_ttl,
cache_ignore_s3_path,
dedicated_worker,
language,
priority,
apply_preprocessor: false,
custom_debounce_key,
debounce_delay_s,
debouncing_settings: DebouncingSettings {
custom_key: custom_debounce_key,
delay_s: debounce_delay_s,
..Default::default()
},
concurrency_settings: ConcurrencySettings {
concurrency_key: custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
},
},
if schedule.tag.as_ref().is_some_and(|x| x != "") {
schedule.tag.clone()
+5 -18
View File
@@ -366,9 +366,7 @@ async fn execute_windmill_tool(
language,
lock,
tag,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
..
} => {
let path = path
@@ -379,33 +377,22 @@ async fn execute_windmill_tool(
content,
language,
lock,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
tool_module,
tag,
tool_module.delete_after_use.unwrap_or(false),
)
}
FlowModuleValue::FlowScript {
id,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
tag,
..
} => {
FlowModuleValue::FlowScript { id, language, concurrency_settings, tag, .. } => {
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
let payload = JobPayloadWithTag {
payload: JobPayload::FlowScript {
id,
language,
custom_concurrency_key: custom_concurrency_key.clone(),
concurrent_limit,
concurrency_time_window_s,
concurrency_settings: concurrency_settings.into(),
cache_ttl: tool_module.cache_ttl.map(|x| x as i32),
cache_ignore_s3_path: tool_module.cache_ignore_s3_path.clone(),
dedicated_worker: None,
path,
},
+1
View File
@@ -118,6 +118,7 @@ pub struct AIAgentArgs {
pub user_images: Option<Vec<S3Object>>,
pub streaming: Option<bool>,
pub messages_context_length: Option<usize>,
pub max_iterations: Option<usize>,
}
#[derive(Deserialize, Debug)]
+9 -3
View File
@@ -75,7 +75,8 @@ lazy_static::lazy_static! {
};
}
const MAX_AGENT_ITERATIONS: usize = 10;
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
pub async fn handle_ai_agent_job(
// connection
@@ -547,8 +548,13 @@ pub async fn run_agent(
None
};
let max_iterations = args
.max_iterations
.map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS))
.unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS);
// Main agent loop
for i in 0..MAX_AGENT_ITERATIONS {
for i in 0..max_iterations {
if used_structured_output_tool {
break;
}
@@ -717,7 +723,7 @@ pub async fn run_agent(
if tool_calls.is_empty() {
break;
} else if i == MAX_AGENT_ITERATIONS - 1 {
} else if i == max_iterations - 1 {
return Err(Error::internal_err(
"AI agent reached max iterations, but there are still tool calls"
.to_string(),
+48 -93
View File
@@ -746,24 +746,48 @@ pub async fn resolve_job_timeout(
}
async fn hash_args(
_db: &DB,
_client: &AuthedClient,
_workspace_id: &str,
#[allow(unused)] db: &DB,
#[allow(unused)] client: &AuthedClient,
#[allow(unused)] workspace_id: &str,
v: &Option<Json<HashMap<String, Box<RawValue>>>>,
hasher: &mut sha2::Sha256,
#[allow(unused)] job_id: &Uuid,
#[allow(unused)] ignore_s3_path: bool,
) {
if let Some(Json(hm)) = v {
for k in hm.keys().sorted() {
hasher.update(k.as_bytes());
let arg_value = hm.get(k).unwrap();
#[cfg(feature = "parquet")]
let (_, arg_additions) =
arg_value_hash_additions(_db, _client, _workspace_id, hm.get(k).unwrap()).await;
hasher.update(arg_value.get().as_bytes());
let etag = match serde_json::from_str::<S3Object>(arg_value.get()).ok() {
Some(s3_object) => {
let s3_resource = get_workspace_s3_resource_path(
db,
client,
workspace_id,
s3_object.storage.as_ref(),
job_id,
)
.await
.ok()
.flatten();
match s3_resource {
Some(s3_resource) => get_etag_or_empty(&s3_resource, s3_object).await,
None => None,
}
}
None => None,
};
#[cfg(feature = "parquet")]
for (_, arg_addition) in arg_additions {
hasher.update(arg_addition.as_bytes());
if let Some(etag) = etag {
hasher.update(etag.as_bytes());
if ignore_s3_path {
continue;
}
}
hasher.update(arg_value.get().as_bytes());
}
}
}
@@ -788,7 +812,16 @@ pub async fn cached_result_path(
_ => {}
}
}
hash_args(db, client, &job.workspace_id, &job.args, &mut hasher).await;
hash_args(
db,
client,
&job.workspace_id,
&job.args,
&mut hasher,
&job.id,
job.cache_ignore_s3_path.unwrap_or(false),
)
.await;
format!("g/results/{:064x}", hasher.finalize())
}
@@ -798,6 +831,7 @@ async fn get_workspace_s3_resource_path(
client: &AuthedClient,
workspace_id: &str,
storage: Option<&String>,
job_id: &Uuid,
) -> windmill_common::error::Result<Option<ObjectStoreResource>> {
use windmill_common::{
job_s3_helpers_oss::get_s3_resource_internal, s3_helpers::StorageResourceType,
@@ -860,7 +894,10 @@ async fn get_workspace_s3_resource_path(
};
let s3_resource_value_raw = client
.get_resource_value::<serde_json::Value>(path.as_str())
.get_resource_value_interpolated::<serde_json::Value>(
path.as_str(),
Some(job_id.to_string()),
)
.await?;
get_s3_resource_internal(
rt,
@@ -872,41 +909,10 @@ async fn get_workspace_s3_resource_path(
.map(Some)
}
#[cfg(feature = "parquet")]
async fn arg_value_hash_additions(
db: &DB,
client: &AuthedClient,
workspace_id: &str,
raw_value: &Box<RawValue>,
) -> (Option<String>, HashMap<String, String>) {
let mut result: HashMap<String, String> = HashMap::new();
let parsed_value = serde_json::from_str::<S3Object>(raw_value.get());
let mut storage = None;
if let Ok(s3_object) = parsed_value {
let s3_resource_opt =
get_workspace_s3_resource_path(db, client, workspace_id, s3_object.storage.as_ref())
.await;
storage = s3_object.storage.clone();
if let Some(mut s3_resource) = s3_resource_opt.ok().flatten() {
let etag = get_etag_or_empty(&mut s3_resource, s3_object.clone()).await;
tracing::warn!("Enriching s3 arg value with etag: {:?}", etag);
result.insert(s3_object.s3.clone(), etag.unwrap_or_default()); // TODO: maybe inject a random value to invalidate the cache?
}
}
return (storage, result);
}
#[derive(Deserialize, Serialize)]
struct CachedResource {
expire: i64,
#[serde(skip_serializing_if = "Option::is_none")]
s3_etags: Option<HashMap<String, String>>,
value: Arc<Box<RawValue>>,
storage: Option<String>,
}
impl CachedResource {
@@ -923,7 +929,6 @@ lazy_static! {
pub async fn get_cached_resource_value_if_valid(
_db: &DB,
client: &AuthedClient,
_job_id: &Uuid,
_workspace_id: &str,
cached_res_path: &str,
) -> Option<Arc<Box<RawValue>>> {
@@ -955,42 +960,6 @@ pub async fn get_cached_resource_value_if_valid(
},
};
#[cfg(feature = "parquet")]
{
let empty_etags = HashMap::new();
let s3_etags = resource.s3_etags.as_ref().unwrap_or(&empty_etags);
let object_store_resource_opt: Option<ObjectStoreResource> = if s3_etags.is_empty() {
None
} else {
get_workspace_s3_resource_path(_db, &client, _workspace_id, resource.storage.as_ref())
.await
.ok()
.flatten()
};
if !s3_etags.is_empty() && object_store_resource_opt.is_none() {
tracing::warn!("Cached result references s3 files that are not retrievable anymore because the workspace S3 resource can't be fetched. Cache will be invalidated");
return None;
}
for (s3_file_key, s3_file_etag) in s3_etags {
if let Some(object_store_resource) = object_store_resource_opt.as_ref() {
let etag = get_etag_or_empty(
object_store_resource,
S3Object {
s3: s3_file_key.clone(),
storage: resource.storage.clone(),
..Default::default()
},
)
.await;
if etag.as_ref() != Some(s3_file_etag) {
tracing::warn!("S3 file etag for '{}' has changed. Value from cache is {:?} while current value from S3 is {:?}. Cache will be invalidated", s3_file_key.clone(), s3_file_etag, etag);
return None;
}
}
}
}
Some(resource.value.clone())
}
@@ -1003,21 +972,7 @@ pub async fn save_in_cache(
) {
let expire = chrono::Utc::now().timestamp() + job.cache_ttl.unwrap() as i64;
#[cfg(feature = "parquet")]
let (storage, s3_etags) =
arg_value_hash_additions(db, _client, job.workspace_id.as_str(), &r).await;
#[cfg(feature = "parquet")]
let s3_etags = if s3_etags.is_empty() {
None
} else {
Some(s3_etags)
};
#[cfg(not(feature = "parquet"))]
let (storage, s3_etags) = (None, None);
let store_cache_resource = CachedResource { expire, s3_etags, value: r, storage };
let store_cache_resource = CachedResource { expire, value: r };
let raw_json = Json(&store_cache_resource);
if let Err(e) = sqlx::query!(
+2 -2
View File
@@ -2729,7 +2729,6 @@ pub async fn handle_queued_job(
let cached_result_maybe = get_cached_resource_value_if_valid(
db,
&client,
&job.id,
&job.workspace_id,
&cached_res_path,
)
@@ -2828,7 +2827,7 @@ pub async fn handle_queued_job(
}
#[cfg(not(feature = "enterprise"))]
if job.concurrent_limit.is_some() {
if job.concurrent_limit.is_some() && !job.kind.is_dependency() {
logs.push_str("---\n");
logs.push_str("WARNING: This job has concurrency limits enabled. Concurrency limits are an EE feature and the setting is ignored.\n");
logs.push_str("---\n");
@@ -4282,6 +4281,7 @@ pub fn init_worker_internal_server_inline_utils(
timeout: None,
flow_step_id: None,
cache_ttl: None,
cache_ignore_s3_path: None,
priority: None,
preprocessed: None,
script_entrypoint_override: None,
+33 -43
View File
@@ -43,7 +43,8 @@ use windmill_common::flow_status::{
};
use windmill_common::flows::{add_virtual_items_if_necessary, Branch, FlowNodeId, StopAfterIf};
use windmill_common::jobs::{
script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE,
script_path_to_payload, ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings,
JobKind, JobPayload, OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE,
};
use windmill_common::scripts::ScriptHash;
use windmill_common::users::username_to_permissioned_as;
@@ -3956,9 +3957,7 @@ async fn compute_next_flow_transform(
language,
lock,
tag,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
..
} => {
let path = path.unwrap_or_else(|| get_path(flow_job, status, module));
@@ -3968,9 +3967,7 @@ async fn compute_next_flow_transform(
content,
language,
lock,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
module,
tag,
delete_after_use,
@@ -3984,9 +3981,7 @@ async fn compute_next_flow_transform(
id, // flow_node(id).
tag,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
..
} => {
let path = get_path(flow_job, status, module);
@@ -3995,10 +3990,9 @@ async fn compute_next_flow_transform(
payload: JobPayload::FlowScript {
id,
language,
custom_concurrency_key: custom_concurrency_key.clone(),
concurrent_limit,
concurrency_time_window_s,
concurrency_settings: concurrency_settings.into(),
cache_ttl: module.cache_ttl.map(|x| x as i32),
cache_ignore_s3_path: module.cache_ignore_s3_path.clone(),
dedicated_worker: None,
path,
},
@@ -4625,18 +4619,14 @@ async fn payload_from_simple_module(
language,
lock,
tag,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
..
} => raw_script_to_payload(
path.unwrap_or_else(|| inner_path),
content,
language,
lock,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
module,
tag,
delete_after_use,
@@ -4645,20 +4635,17 @@ async fn payload_from_simple_module(
id, // flow_node(id).
tag,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
concurrency_settings,
..
} => JobPayloadWithTag {
payload: JobPayload::FlowScript {
id,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
cache_ttl: module.cache_ttl.map(|x| x as i32),
cache_ignore_s3_path: module.cache_ignore_s3_path,
dedicated_worker: None,
path: inner_path,
concurrency_settings: concurrency_settings.into(),
},
tag,
delete_after_use,
@@ -4674,9 +4661,7 @@ pub fn raw_script_to_payload(
content: String,
language: windmill_common::scripts::ScriptLang,
lock: Option<String>,
custom_concurrency_key: Option<String>,
concurrent_limit: Option<i32>,
concurrency_time_window_s: Option<i32>,
concurrency_settings: ConcurrencySettingsWithCustom,
module: &FlowModule,
tag: Option<String>,
delete_after_use: bool,
@@ -4688,13 +4673,12 @@ pub fn raw_script_to_payload(
content,
language,
lock,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
cache_ttl: module.cache_ttl.map(|x| x as i32),
cache_ignore_s3_path: module.cache_ignore_s3_path,
dedicated_worker: None,
custom_debounce_key: None,
debounce_delay_s: None,
concurrency_settings,
// TODO: Should this have debouncing?
debouncing_settings: DebouncingSettings::default(),
}),
tag,
delete_after_use,
@@ -4756,11 +4740,6 @@ pub async fn script_to_payload(
let ScriptHashInfo {
tag,
concurrency_key,
concurrent_limit,
concurrency_time_window_s,
debounce_key,
debounce_delay_s,
cache_ttl,
language,
dedicated_worker,
@@ -4769,6 +4748,11 @@ pub async fn script_to_payload(
timeout,
on_behalf_of_email,
created_by,
concurrency_key,
concurrent_limit,
concurrency_time_window_s,
debounce_key,
debounce_delay_s,
..
} = get_script_info_for_hash(None, db, &flow_job.workspace_id, hash.0).await?;
let on_behalf_of = if let Some(email) = on_behalf_of_email {
@@ -4784,12 +4768,18 @@ pub async fn script_to_payload(
JobPayload::ScriptHash {
hash,
path: script_path,
custom_concurrency_key: concurrency_key,
concurrent_limit,
concurrency_time_window_s,
custom_debounce_key: debounce_key,
debounce_delay_s,
debouncing_settings: DebouncingSettings {
custom_key: debounce_key,
delay_s: debounce_delay_s,
..Default::default()
},
concurrency_settings: ConcurrencySettings {
concurrency_key,
concurrent_limit,
concurrency_time_window_s,
},
cache_ttl: module.cache_ttl.map(|x| x as i32).ok_or(cache_ttl).ok(),
cache_ignore_s3_path: module.cache_ignore_s3_path,
language,
dedicated_worker,
priority,
@@ -1171,11 +1171,9 @@ async fn lock_modules<'c>(
mut language,
input_transforms,
tag,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
assets,
concurrency_settings,
} = e.get_value()?
else {
let mut nmodified_ids = Vec::new();
@@ -1541,11 +1539,9 @@ async fn lock_modules<'c>(
content,
language,
tag,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
assets,
concurrency_settings,
});
new_flow_modules.push(e);
@@ -1737,11 +1733,9 @@ async fn reduce_flow<'c>(
language,
input_transforms,
tag,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
assets,
concurrency_settings,
..
} = std::mem::replace(&mut val, Identity)
else {
@@ -1764,11 +1758,9 @@ async fn reduce_flow<'c>(
id,
tag,
language,
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
assets,
concurrency_settings,
};
}
ForloopFlow { modules, modules_node, .. }
+9 -5
View File
@@ -395,15 +395,19 @@ pub async fn get_tag_and_concurrency(job_id: &Uuid, db: &DB) -> Option<TagAndCon
Err(_) => cache::flow::fetch_version(db, version).await,
};
let flow_value = flow.map(|f| f.value().clone()).ok();
let concurrency_key = flow_value
.as_ref()
.map(|fv| fv.concurrency_key.clone())
.flatten();
let concurrent_limit = flow_value.as_ref().map(|fv| fv.concurrent_limit).flatten();
.and_then(|fv| fv.concurrency_settings.concurrency_key.to_owned());
let concurrent_limit = flow_value
.as_ref()
.and_then(|fv| fv.concurrency_settings.concurrent_limit);
let concurrent_time_window_s = flow_value
.as_ref()
.map(|fv| fv.concurrency_time_window_s)
.flatten();
.and_then(|fv| fv.concurrency_settings.concurrency_time_window_s);
Some(TagAndConcurrencyKey {
tag: tag_and_concurrency_key.tag,
concurrency_key,
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.589.1";
export const VERSION = "v1.589.3";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
Symlink
+1
View File
@@ -0,0 +1 @@
/usr/bin/cursor
+1 -1
View File
@@ -57,7 +57,7 @@ export { WebSocketServer, WebSocket } from "npm:ws";
export * as getPort from "npm:get-port@7.1.0";
export * as open from "npm:open";
export * as esMain from "npm:es-main";
export * as windmillUtils from "jsr:@windmill-labs/shared-utils@1.0.10";
export * as windmillUtils from "jsr:@windmill-labs/shared-utils@1.0.11";
// needed for dnt transform
import * as wsTypes from "npm:@types/ws";
+125 -79
View File
@@ -20,15 +20,19 @@ import {
ScriptLanguage,
workspaceDependenciesLanguages,
} from "../../utils/script_common.ts";
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts";
import { exts } from "../script/script.ts";
import { FSFSElement, yamlOptions } from "../sync/sync.ts";
import { Workspace } from "../workspace/workspace.ts";
import { AppFile as RawAppFile } from "./raw_apps.ts";
import {
AppFile as RawAppFile,
loadRunnablesFromBackend,
writeRunnableToBackend,
} from "./raw_apps.ts";
import { replaceInlineScripts, AppFile as NormalAppFile } from "./apps.ts";
import {
newPathAssigner,
newRawAppPathAssigner,
SupportedLanguage,
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { mergeConfigWithConfigFile, SyncOptions } from "../../core/conf.ts";
@@ -164,21 +168,29 @@ export async function generateAppLocksInternal(
);
if (rawApp) {
const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER) + SEP;
const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER);
// Load runnables from separate files (new format) or fall back to raw_app.yaml (old format)
const rawAppFile = appFile as RawAppFile;
let runnables = await loadRunnablesFromBackend(runnablesPath);
if (Object.keys(runnables).length === 0 && rawAppFile.runnables) {
// Fall back to old format
runnables = rawAppFile.runnables;
}
// Replace inline scripts for changed runnables
replaceInlineScripts(rawAppFile.runnables, runnablesPath, false);
replaceInlineScripts(runnables, runnablesPath + SEP, false);
// Update the app runnables with new locks
rawAppFile.runnables = await updateRawAppRunnables(
// Update the app runnables with new locks (writes to separate files)
await updateRawAppRunnables(
workspace,
rawAppFile.runnables,
runnables,
remote_path,
appFolder,
rawWorkspaceDependencies,
opts.defaultTs
);
// Note: updateRawAppRunnables now writes each runnable to its own file
} else {
const normalAppFile = appFile as NormalAppFile;
@@ -194,13 +206,13 @@ export async function generateAppLocksInternal(
rawWorkspaceDependencies,
opts.defaultTs
);
}
// Write the updated app file
writeIfChanged(
appFilePath,
yamlStringify(appFile as Record<string, any>, yamlOptions)
);
// Write the updated app file (only for normal apps, raw apps use separate files)
writeIfChanged(
appFilePath,
yamlStringify(appFile as Record<string, any>, yamlOptions)
);
}
} else {
log.info(colors.gray(`No scripts changed in ${appFolder}`));
}
@@ -279,8 +291,9 @@ async function traverseAndProcessInlineScripts(
}
/**
* Updates locks for all runnables in a raw app, generating locks inline script by inline script
* Also writes content and locks back to the runnables folder
* Updates locks for all runnables in a raw app, generating locks inline script by inline script.
* Writes each runnable to its own YAML file in the backend folder (new format).
* Also writes content and lock files to the runnables folder.
*/
async function updateRawAppRunnables(
workspace: Workspace,
@@ -289,7 +302,7 @@ async function updateRawAppRunnables(
appFolder: string,
rawDeps?: Record<string, string>,
defaultTs: "bun" | "deno" = "bun"
): Promise<Record<string, any>> {
): Promise<void> {
const runnablesFolder = path.join(appFolder, APP_BACKEND_FOLDER);
// Ensure runnables folder exists
@@ -299,15 +312,16 @@ async function updateRawAppRunnables(
// Folder may already exist
}
const pathAssigner = newPathAssigner(defaultTs);
// Process each runnable
const updatedRunnables: Record<string, any> = {};
const pathAssigner = newRawAppPathAssigner(defaultTs);
for (const [runnableId, runnable] of Object.entries(runnables)) {
// Only process inline scripts (runnableByName with inlineScript)
if (runnable?.type !== "runnableByName" || !runnable?.inlineScript) {
updatedRunnables[runnableId] = runnable;
// Only process inline scripts (runnableByName/inline with inlineScript)
if (
(runnable?.type !== "runnableByName" && runnable?.type !== "inline") ||
!runnable?.inlineScript
) {
// Write non-inline runnables to their own file as-is
writeRunnableToBackend(runnablesFolder, runnableId, runnable);
continue;
}
@@ -316,7 +330,7 @@ async function updateRawAppRunnables(
const content = inlineScript.content;
if (!content || !language) {
updatedRunnables[runnableId] = runnable;
writeRunnableToBackend(runnablesFolder, runnableId, runnable);
continue;
}
@@ -327,13 +341,30 @@ async function updateRawAppRunnables(
`Runnable ${runnableId} content is still an !inline reference, skipping`
)
);
updatedRunnables[runnableId] = runnable;
writeRunnableToBackend(runnablesFolder, runnableId, runnable);
continue;
}
// Skip frontend scripts - they don't need locks
if (language === "frontend") {
updatedRunnables[runnableId] = runnable;
// Still need to write the runnable YAML file
const [basePathO, ext] = pathAssigner.assignPath(
runnable.name ?? runnableId,
language
);
const basePath = basePathO.replaceAll(SEP, "/");
const contentPath = path.join(runnablesFolder, `${basePath}${ext}`);
writeIfChanged(contentPath, content);
// Write simplified runnable YAML - just type: 'inline' plus metadata
// inlineScript is not needed since content/language can be derived from files
const simplifiedRunnable: Record<string, any> = { type: "inline" };
for (const [key, value] of Object.entries(runnable)) {
if (key !== "inlineScript" && key !== "type") {
simplifiedRunnable[key] = value;
}
}
writeRunnableToBackend(runnablesFolder, runnableId, simplifiedRunnable);
continue;
}
@@ -354,7 +385,10 @@ async function updateRawAppRunnables(
);
// Determine file extension for this language
const [basePathO, ext] = pathAssigner.assignPath(runnable.name, language);
const [basePathO, ext] = pathAssigner.assignPath(
runnable.name ?? runnableId,
language
);
const basePath = basePathO.replaceAll(SEP, "/");
const contentPath = path.join(runnablesFolder, `${basePath}${ext}`);
const lockPath = path.join(runnablesFolder, `${basePath}lock`);
@@ -367,23 +401,21 @@ async function updateRawAppRunnables(
writeIfChanged(lockPath, lock);
}
// Update the runnable with !inline references (preserve existing schema)
const inlineContentRef = `!inline ${basePath}${ext}`;
const inlineLockRef =
lock && lock !== "" ? `!inline ${basePath}lock` : "";
// Write simplified runnable YAML - just type: 'inline' plus metadata
// inlineScript is not needed since content/lock/language can be derived from files
const simplifiedRunnable: Record<string, any> = { type: "inline" };
for (const [key, value] of Object.entries(runnable)) {
if (key !== "inlineScript" && key !== "type") {
simplifiedRunnable[key] = value;
}
}
updatedRunnables[runnableId] = {
...runnable,
inlineScript: {
...inlineScript,
content: inlineContentRef,
lock: inlineLockRef,
},
};
// Write the runnable to its own YAML file
writeRunnableToBackend(runnablesFolder, runnableId, simplifiedRunnable);
log.info(
colors.gray(
` Written ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}`
` Written ${runnableId}.yaml, ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}`
)
);
} catch (error: any) {
@@ -392,12 +424,10 @@ async function updateRawAppRunnables(
`Failed to generate lock for runnable ${runnableId}: ${error.message}`
)
);
// Continue with other runnables even if one fails
updatedRunnables[runnableId] = runnable;
// Write the original runnable even if lock generation fails
writeRunnableToBackend(runnablesFolder, runnableId, runnable);
}
}
return updatedRunnables;
}
/**
@@ -434,33 +464,30 @@ async function updateAppInlineScripts(
return inlineScript;
}
// Skip frontend scripts - they don't need locks
if (language === "frontend") {
return inlineScript;
}
// Get the name from the parent object (following extractInlineScriptsForApps pattern)
// For normal apps, the name is stored in the component's "name" property
const scriptName = context.parentObject?.["name"] || "unnamed";
const scriptPath = `${remotePath}/${context.path.join("/")}`;
log.info(
colors.gray(
`Generating lock for inline script "${scriptName}" at ${context.path.join(
"."
)} (${language})`
)
);
try {
const lock = await generateInlineScriptLock(
workspace,
content,
language,
scriptPath,
rawDeps
);
let lock: string | undefined;
if (language !== "frontend") {
log.info(
colors.gray(
`Generating lock for inline script "${scriptName}" at ${context.path.join(
"."
)} (${language})`
)
);
lock = await generateInlineScriptLock(
workspace,
content,
language,
scriptPath,
rawDeps
);
}
// Determine file extension for this language (following extractInlineScriptsForApps pattern)
const [basePathO, ext] = pathAssigner.assignPath(scriptName, language);
const basePath = basePathO.replaceAll(SEP, "/");
@@ -489,7 +516,7 @@ async function updateAppInlineScripts(
return {
...inlineScript,
content: inlineContentRef,
lock: inlineLockRef,
...(lock ? { lock: inlineLockRef } : {}),
};
} catch (error: any) {
log.error(
@@ -589,7 +616,7 @@ export interface InferredSchemaResult {
/**
* Infers schema for a single runnable from its file content.
* Used by dev server to update schema in memory (for wmill.d.ts generation).
* Does NOT write to raw_app.yaml - schema is kept in memory only.
* Does NOT write to the runnable YAML file - schema is kept in memory only.
*
* @param appFolder - The folder containing the raw app
* @param runnableFilePath - The path to the changed runnable file (relative to runnables folder)
@@ -599,33 +626,52 @@ export async function inferRunnableSchemaFromFile(
appFolder: string,
runnableFilePath: string
): Promise<InferredSchemaResult | undefined> {
// Extract runnable ID from file path (e.g., "myRunnable.inline_script.ts" -> "myRunnable")
// Extract runnable ID from file path (e.g., "myRunnable.ts" -> "myRunnable")
const fileName = path.basename(runnableFilePath);
// Skip lock files
if (fileName.endsWith(".lock")) {
// Skip lock files and yaml files (runnable metadata)
if (fileName.endsWith(".lock") || fileName.endsWith(".yaml")) {
return undefined;
}
// Match pattern: {runnableId}.inline_script.{ext}
const match = fileName.match(/^(.+)\.inline_script\.[^.]+$/);
// Match pattern: {runnableId}.{ext} - extract the runnable ID (everything before the last dot)
const match = fileName.match(/^(.+)\.[^.]+$/);
if (!match) {
return undefined;
}
const runnableId = match[1];
// Read the app file to get the language
const appFilePath = path.join(appFolder, "raw_app.yaml");
const appFile = (await yamlParseFile(appFilePath)) as RawAppFile;
// Read the runnable from its separate YAML file (new format)
const runnableFilePath2 = path.join(
appFolder,
APP_BACKEND_FOLDER,
`${runnableId}.yaml`
);
if (!appFile.runnables?.[runnableId]) {
log.warn(colors.yellow(`Runnable ${runnableId} not found in raw_app.yaml`));
return undefined;
let runnable: any;
try {
runnable = await yamlParseFile(runnableFilePath2);
} catch {
// Fall back to reading from raw_app.yaml (old format)
try {
const appFilePath = path.join(appFolder, "raw_app.yaml");
const appFile = (await yamlParseFile(appFilePath)) as RawAppFile;
if (!appFile.runnables?.[runnableId]) {
log.warn(
colors.yellow(`Runnable ${runnableId} not found in backend folder or raw_app.yaml`)
);
return undefined;
}
runnable = appFile.runnables[runnableId];
} catch {
log.warn(
colors.yellow(`Could not read runnable ${runnableId} from any source`)
);
return undefined;
}
}
const runnable = appFile.runnables[runnableId];
// Only process inline scripts
if (!runnable?.inlineScript) {
return undefined;
+2
View File
@@ -16,6 +16,7 @@ import { ListableApp, Policy } from "../../../gen/types.gen.ts";
import { GlobalOptions, isSuperset } from "../../types.ts";
import { readInlinePathSync } from "../../utils/utils.ts";
import devCommand from "./dev.ts";
import lintCommand from "./lint.ts";
import { isVersionsGeq1585 } from "../sync/global.ts";
export interface AppFile {
@@ -230,6 +231,7 @@ const command = new Command()
.arguments("<file_path:string> <remote_path:string>")
.action(push as any)
.command("dev", devCommand)
.command("lint", lintCommand)
.command(
"generate-locks",
"re-generate the lockfiles for app runnables inline scripts that have changed"
+15 -10
View File
@@ -206,16 +206,21 @@ export async function createBundle(
const wmillPlugin = {
name: "wmill-virtual",
setup(build: any) {
// Intercept imports of /wmill.ts, /wmill, ./wmill.ts, or ./wmill
build.onResolve({ filter: /^(\.\/|\/)?wmill(\.ts)?$/ }, (args: any) => {
log.info(colors.yellow(`[wmill-virtual] Intercepted: ${args.path}`));
return {
path: args.path,
namespace: "wmill-virtual",
};
});
// Intercept imports of wmill with various path formats:
// - wmill, wmill.ts (bare import)
// - /wmill, /wmill.ts (absolute)
// - ./wmill, ./wmill.ts (same directory)
// - ../wmill, ../../wmill, etc. (parent directories)
build.onResolve(
{ filter: /^(\.\.\/)+wmill(\.ts)?$|^(\.\/|\/)?wmill(\.ts)?$/ },
(args: any) => {
log.info(colors.yellow(`[wmill-virtual] Intercepted: ${args.path}`));
return {
path: args.path,
namespace: "wmill-virtual",
};
}
);
// Provide the virtual module content
build.onLoad({ filter: /.*/, namespace: "wmill-virtual" }, (args: any) => {
+58 -30
View File
@@ -34,6 +34,7 @@ import {
APP_BACKEND_FOLDER,
inferRunnableSchemaFromFile,
} from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
const DEFAULT_PORT = 4000;
const DEFAULT_HOST = "localhost";
@@ -189,14 +190,21 @@ async function dev(opts: DevOptions) {
const wmillPlugin = {
name: "wmill-virtual",
setup(build: any) {
// Intercept imports of /wmill.ts, /wmill, ./wmill.ts, or ./wmill
build.onResolve({ filter: /^(\.\/|\/)?wmill(\.ts)?$/ }, (args: any) => {
log.info(colors.yellow(`[wmill-virtual] Intercepted: ${args.path}`));
return {
path: args.path,
namespace: "wmill-virtual",
};
});
// Intercept imports of wmill with various path formats:
// - wmill, wmill.ts (bare import)
// - /wmill, /wmill.ts (absolute)
// - ./wmill, ./wmill.ts (same directory)
// - ../wmill, ../../wmill, etc. (parent directories)
build.onResolve(
{ filter: /^(\.\.\/)+wmill(\.ts)?$|^(\.\/|\/)?wmill(\.ts)?$/ },
(args: any) => {
log.info(colors.yellow(`[wmill-virtual] Intercepted: ${args.path}`));
return {
path: args.path,
namespace: "wmill-virtual",
};
}
);
// Provide the virtual module content
build.onLoad(
@@ -665,19 +673,35 @@ export default command;
/**
* Generates wmill.d.ts with type definitions for runnables.
* Merges in-memory inferred schemas with runnables from raw_app.yaml.
* Loads runnables from separate YAML files in the backend folder (new format)
* or falls back to raw_app.yaml (old format).
* Merges in-memory inferred schemas with runnables.
*
* @param schemaOverrides - In-memory schema overrides (runnableId -> schema)
*/
async function genRunnablesTs(schemaOverrides: Record<string, any> = {}) {
log.info(colors.blue("🔄 Generating wmill.d.ts..."));
const rawApp = (await yamlParseFile(
path.join(process.cwd(), "raw_app.yaml")
)) as any;
const runnables = rawApp?.["runnables"] as any;
const localPath = process.cwd();
const backendPath = path.join(localPath, APP_BACKEND_FOLDER);
// Load runnables from separate files (new format) or fall back to raw_app.yaml (old format)
let runnables = await loadRunnablesFromBackend(backendPath);
if (Object.keys(runnables).length === 0) {
// Fall back to old format
try {
const rawApp = (await yamlParseFile(
path.join(localPath, "raw_app.yaml")
)) as any;
runnables = rawApp?.["runnables"] ?? {};
} catch {
runnables = {};
}
}
// Apply schema overrides from in-memory cache
if (runnables && Object.keys(schemaOverrides).length > 0) {
if (Object.keys(schemaOverrides).length > 0) {
for (const [runnableId, schema] of Object.entries(schemaOverrides)) {
if (runnables[runnableId]?.inlineScript) {
runnables[runnableId].inlineScript.schema = schema;
@@ -697,16 +721,22 @@ async function genRunnablesTs(schemaOverrides: Record<string, any> = {}) {
async function loadRunnables(): Promise<Record<string, Runnable>> {
try {
const localPath = process.cwd();
const rawApp = (await yamlParseFile(
path.join(localPath, "raw_app.yaml")
)) as any;
replaceInlineScripts(
rawApp.runnables,
path.join(localPath, APP_BACKEND_FOLDER) + SEP,
true
);
const backendPath = path.join(localPath, APP_BACKEND_FOLDER);
return rawApp?.runnables ?? {};
// Load runnables from separate files (new format) or fall back to raw_app.yaml (old format)
let runnables = await loadRunnablesFromBackend(backendPath);
if (Object.keys(runnables).length === 0) {
// Fall back to old format
const rawApp = (await yamlParseFile(
path.join(localPath, "raw_app.yaml")
)) as any;
runnables = rawApp?.runnables ?? {};
}
replaceInlineScripts(runnables, backendPath + SEP, true);
return runnables;
} catch (error: any) {
log.error(colors.red(`Failed to load runnables: ${error.message}`));
return {};
@@ -755,14 +785,12 @@ async function executeRunnable(
lock: inlineScript.id === undefined ? inlineScript.lock : undefined,
cache_ttl: inlineScript.cache_ttl,
};
} else if (
(runnable.type === "path" || runnable.type === "runnableByPath") &&
runnable.path
) {
const runType = runnable.runType ?? "script";
} else if (runnable.type === "path" && runnable.runType && runnable.path) {
// Path-based runnables have type: "path" and runType: "script"|"hubscript"|"flow"
const prefix = runnable.runType;
requestBody.path =
runType !== "hubscript"
? `${runType}/${runnable.path}`
prefix !== "hubscript"
? `${prefix}/${runnable.path}`
: `script/${runnable.path}`;
}
+226
View File
@@ -0,0 +1,226 @@
// deno-lint-ignore-file no-explicit-any
import * as fs from "node:fs";
import * as path from "node:path";
import process from "node:process";
import { Command, colors, log, yamlParseFile } from "../../../deps.ts";
import { GlobalOptions } from "../../types.ts";
import { createBundle } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
interface LintOptions extends GlobalOptions {
fix?: boolean;
}
interface LintResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Validates the structure of raw_app.yaml
*/
function validateRawAppYaml(appData: any): {
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// Check required fields
if (!appData.summary) {
errors.push("Missing required field: 'summary'");
} else if (typeof appData.summary !== "string") {
errors.push("Field 'summary' must be a string");
}
// Note: 'runnables' is no longer required in raw_app.yaml
// Runnables can be stored in separate files in the backend folder
return { errors, warnings };
}
/**
* Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml
*/
async function validateRunnables(
appDir: string,
appData: any
): Promise<{ errors: string[]; warnings: string[] }> {
const errors: string[] = [];
const warnings: string[] = [];
const backendPath = path.join(appDir, APP_BACKEND_FOLDER);
// Load runnables from separate files (new format)
const runnablesFromBackend = await loadRunnablesFromBackend(backendPath);
const hasBackendRunnables = Object.keys(runnablesFromBackend).length > 0;
// Check for runnables in raw_app.yaml (old format)
const hasYamlRunnables =
appData.runnables &&
typeof appData.runnables === "object" &&
!Array.isArray(appData.runnables) &&
Object.keys(appData.runnables).length > 0;
if (!hasBackendRunnables && !hasYamlRunnables) {
errors.push(
"No runnables found. Expected either:\n" +
" - Runnable YAML files in the 'backend/' folder (e.g., backend/myRunnable.yaml)\n" +
" - Or a 'runnables' field in raw_app.yaml (legacy format)"
);
} else if (hasBackendRunnables) {
log.info(
colors.gray(
` Found ${Object.keys(runnablesFromBackend).length} runnable(s) in backend folder`
)
);
} else if (hasYamlRunnables) {
log.info(
colors.gray(
` Found ${Object.keys(appData.runnables).length} runnable(s) in raw_app.yaml (legacy format)`
)
);
warnings.push(
"Using legacy format with runnables in raw_app.yaml. Consider migrating to separate files in backend/"
);
}
return { errors, warnings };
}
/**
* Checks if the app can be built successfully
*/
async function validateBuild(
appDir: string
): Promise<{ errors: string[]; warnings: string[] }> {
const errors: string[] = [];
const warnings: string[] = [];
try {
log.info(colors.blue("🔨 Testing build..."));
// Try to create a bundle - this will validate that all dependencies are in place
await createBundle({
production: true,
minify: false,
});
log.info(colors.green("✅ Build successful"));
} catch (error: any) {
errors.push(`Build failed: ${error.message}`);
}
return { errors, warnings };
}
/**
* Validates a raw app folder
*/
async function lintRawApp(
appDir: string,
opts: LintOptions
): Promise<LintResult> {
const errors: string[] = [];
const warnings: string[] = [];
// Check if we're in a .raw_app folder
const currentDirName = path.basename(appDir);
if (!currentDirName.endsWith(".raw_app")) {
errors.push(
`Not a raw app folder: '${currentDirName}' does not end with '.raw_app'`
);
return { valid: false, errors, warnings };
}
// Check if raw_app.yaml exists
const rawAppPath = path.join(appDir, "raw_app.yaml");
if (!fs.existsSync(rawAppPath)) {
errors.push("Missing raw_app.yaml file");
return { valid: false, errors, warnings };
}
log.info(colors.blue("📋 Validating raw_app.yaml structure..."));
// Parse and validate raw_app.yaml
let appData: any;
try {
appData = await yamlParseFile(rawAppPath);
} catch (error: any) {
errors.push(`Failed to parse raw_app.yaml: ${error.message}`);
return { valid: false, errors, warnings };
}
const yamlValidation = validateRawAppYaml(appData);
errors.push(...yamlValidation.errors);
warnings.push(...yamlValidation.warnings);
if (errors.length > 0) {
return { valid: false, errors, warnings };
}
log.info(colors.green("✅ raw_app.yaml structure is valid"));
// Validate runnables (either in backend folder or in raw_app.yaml)
log.info(colors.blue("📋 Validating runnables..."));
const runnablesValidation = await validateRunnables(appDir, appData);
errors.push(...runnablesValidation.errors);
warnings.push(...runnablesValidation.warnings);
if (errors.length > 0) {
return { valid: false, errors, warnings };
}
log.info(colors.green("✅ Runnables are valid"));
// Validate build
const buildValidation = await validateBuild(appDir);
errors.push(...buildValidation.errors);
warnings.push(...buildValidation.warnings);
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Main lint command
*/
async function lint(opts: LintOptions, appFolder?: string) {
const targetDir = appFolder ?? process.cwd();
log.info(colors.bold.blue(`\n🔍 Linting raw app: ${targetDir}\n`));
const result = await lintRawApp(targetDir, opts);
// Display results
if (result.warnings.length > 0) {
log.info(colors.yellow("\n⚠️ Warnings:"));
result.warnings.forEach((warning) => {
log.info(colors.yellow(` - ${warning}`));
});
}
if (result.errors.length > 0) {
log.info(colors.red("\n❌ Errors:"));
result.errors.forEach((error) => {
log.info(colors.red(` - ${error}`));
});
log.info(colors.red("\n❌ Lint failed\n"));
Deno.exit(1);
}
log.info(colors.green("\n✅ All checks passed\n"));
}
const command = new Command()
.description("Lint a raw app folder to validate structure and buildability")
.arguments("[app_folder:string]")
.option("--fix", "Attempt to fix common issues (not implemented yet)")
.action(lint as any);
export default command;
+2 -3
View File
@@ -13,9 +13,8 @@ export type Runnable =
fields?: Record<string, any>;
}
| {
type: "runnableByPath" | "path";
type: "path";
runType: "script" | "hubscript" | "flow";
path: string;
runType?: "script" | "flow" | "hubscript";
fields?: Record<string, any>;
schema?: any;
};
+223 -27
View File
@@ -7,24 +7,197 @@ import {
SEP,
windmillUtils,
yamlParseFile,
yamlStringify,
} from "../../../deps.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { Policy } from "../../../gen/types.gen.ts";
import path from "node:path";
import { GlobalOptions, isSuperset } from "../../types.ts";
import { replaceInlineScripts, repopulateFields } from "./apps.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { writeIfChanged } from "../../utils/utils.ts";
import { yamlOptions } from "../sync/sync.ts";
import {
EXTENSION_TO_LANGUAGE,
getLanguageFromExtension,
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
export interface AppFile {
runnables: any;
runnables?: any;
custom_path: string;
public?: boolean;
summary: string;
policy: Policy;
}
/**
* Finds the content file for a runnable by looking for files matching the runnableId.
* Returns the file extension and content, or undefined if not found.
*/
async function findRunnableContentFile(
backendPath: string,
runnableId: string,
allFiles: string[]
): Promise<{ ext: string; content: string } | undefined> {
// Look for files matching pattern: {runnableId}.{ext}
// where ext is a known language extension
for (const fileName of allFiles) {
// Skip yaml and lock files
if (fileName.endsWith(".yaml") || fileName.endsWith(".lock")) {
continue;
}
// Check if file starts with runnableId followed by a dot
if (!fileName.startsWith(runnableId + ".")) {
continue;
}
// Extract extension (everything after the first dot following runnableId)
const ext = fileName.substring(runnableId.length + 1);
// Check if this is a recognized extension
if (EXTENSION_TO_LANGUAGE[ext]) {
try {
const content = await Deno.readTextFile(
path.join(backendPath, fileName)
);
return { ext, content };
} catch {
continue;
}
}
}
return undefined;
}
/**
* Loads all runnables from separate YAML files in the backend folder.
* Each runnable is stored in a file named `<runnableId>.yaml`.
*
* Converts from file format to API format:
* - For inline scripts (type: 'inline'): derives inlineScript from sibling files
* - For path-based runnables (type: 'script'|'hubscript'|'flow'): converts to API format
* e.g., { type: "script" } -> { type: "path", runType: "script" }
*
* Returns an empty object if the backend folder doesn't exist.
*
* @param backendPath - Path to the backend folder
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
*/
export async function loadRunnablesFromBackend(
backendPath: string,
defaultTs: "bun" | "deno" = "bun"
): Promise<Record<string, any>> {
const runnables: Record<string, any> = {};
try {
// First, collect all files in the backend folder
const allFiles: string[] = [];
for await (const entry of Deno.readDir(backendPath)) {
if (entry.isFile) {
allFiles.push(entry.name);
}
}
// Process YAML files (runnable metadata files)
for (const fileName of allFiles) {
if (!fileName.endsWith(".yaml")) {
continue;
}
const runnableId = fileName.replace(".yaml", "");
const filePath = path.join(backendPath, fileName);
const runnable = (await yamlParseFile(filePath)) as Record<string, any>;
// If this is an inline script (type: 'inline'), derive inlineScript from files
if (runnable?.type === "inline") {
const contentFile = await findRunnableContentFile(
backendPath,
runnableId,
allFiles
);
if (contentFile) {
const language = getLanguageFromExtension(contentFile.ext, defaultTs);
// Try to load lock file
let lock: string | undefined;
try {
lock = await Deno.readTextFile(
path.join(backendPath, `${runnableId}.lock`)
);
} catch {
// No lock file, that's fine
}
// Reconstruct inlineScript object
runnable.inlineScript = {
content: contentFile.content,
language,
...(lock ? { lock } : {}),
};
}
} else if (
runnable?.type === "script" ||
runnable?.type === "hubscript" ||
runnable?.type === "flow"
) {
// For path-based runnables, convert from file format to API format
// { type: "script" } -> { type: "path", runType: "script" }
// { type: "hubscript" } -> { type: "path", runType: "hubscript" }
// { type: "flow" } -> { type: "path", runType: "flow" }
const { type, schema: _schema, ...rest } = runnable;
runnable.type = "path";
runnable.runType = type;
// Remove schema if present
delete runnable.schema;
Object.assign(runnable, rest);
}
runnables[runnableId] = runnable;
}
} catch (error: any) {
if (error.name !== "NotFound") {
throw error;
}
}
return runnables;
}
/**
* Writes a single runnable to its YAML file in the backend folder.
* The file will be named `<runnableId>.yaml`.
*
* Converts from API format to file format:
* - For inline scripts: keeps type: "inline"
* - For path-based runnables: converts { type: "path", runType: "script" } to { type: "script" }
* and removes schema field
*/
export function writeRunnableToBackend(
backendPath: string,
runnableId: string,
runnable: any
): void {
let runnableToWrite = { ...runnable };
// Convert path-based runnables from API format to file format
if (runnable.type === "path" && runnable.runType) {
// { type: "path", runType: "script" } -> { type: "script" }
const { type: _type, runType, schema: _schema, ...rest } = runnable;
runnableToWrite = {
type: runType,
...rest,
};
}
const filePath = path.join(backendPath, `${runnableId}.yaml`);
writeIfChanged(filePath, yamlStringify(runnableToWrite, yamlOptions));
}
const alreadySynced: string[] = [];
async function collectAppFiles(
@@ -50,10 +223,10 @@ async function collectAppFiles(
await readDirRecursive(fullPath + SEP, relativePath + SEP);
} else if (entry.isFile) {
// Skip raw_app.yaml as it's metadata, not an app file
// Skip node_modules and package-lock.json as they are generated
// Skip package-lock.json as it's generated
if (
relativePath === "raw_app.yaml" ||
relativePath === "package-lock.json"
entry.name === "raw_app.yaml" ||
entry.name === "package-lock.json"
) {
continue;
}
@@ -99,15 +272,48 @@ export async function pushRawApp(
if (!localPath.endsWith(SEP)) {
localPath += SEP;
}
const path = localPath + "raw_app.yaml";
const localApp = (await yamlParseFile(path)) as AppFile;
replaceInlineScripts(
localApp.runnables,
localPath + SEP + APP_BACKEND_FOLDER + SEP,
true
const appFilePath = localPath + "raw_app.yaml";
const localApp = (await yamlParseFile(appFilePath)) as AppFile;
// Load runnables from separate YAML files in the backend folder
// Falls back to reading from raw_app.yaml if no separate files exist (backward compat)
const backendPath = path.join(localPath, APP_BACKEND_FOLDER);
const runnablesFromBackend = await loadRunnablesFromBackend(backendPath);
let runnables: Record<string, any>;
if (Object.keys(runnablesFromBackend).length > 0) {
// Use runnables from separate files (new format)
runnables = runnablesFromBackend;
log.info(
colors.gray(
`Loaded ${Object.keys(runnables).length} runnables from backend folder`
)
);
} else if (localApp.runnables) {
// Fall back to runnables from raw_app.yaml (old format)
runnables = localApp.runnables;
log.info(
colors.gray(
`Loaded ${
Object.keys(runnables).length
} runnables from raw_app.yaml (legacy format)`
)
);
} else {
runnables = {};
}
replaceInlineScripts(runnables, backendPath + SEP, true);
repopulateFields(runnables);
// Create a temporary app object for policy generation
const appForPolicy = { ...localApp, runnables };
await generatingPolicy(
appForPolicy,
remotePath,
localApp?.["public"] ?? false
);
repopulateFields(localApp.runnables);
await generatingPolicy(localApp, remotePath, localApp?.["public"] ?? false);
const files = await collectAppFiles(localPath);
async function createBundleRaw() {
log.info(colors.yellow.bold(`Creating raw app ${remotePath} bundle...`));
@@ -123,7 +329,7 @@ export async function pushRawApp(
});
}
if (app) {
if (isSuperset(localApp, app)) {
if (isSuperset({ ...localApp, runnables }, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));
return;
}
@@ -134,10 +340,10 @@ export async function pushRawApp(
path: remotePath,
formData: {
app: {
value: { runnables: localApp.runnables, files },
value: { runnables, files },
path: remotePath,
summary: localApp.summary,
policy: localApp.policy,
policy: appForPolicy.policy,
deployment_message: message,
custom_path: localApp.custom_path,
},
@@ -151,10 +357,10 @@ export async function pushRawApp(
workspace,
formData: {
app: {
value: { runnables: localApp.runnables, files },
value: { runnables, files },
path: remotePath,
summary: localApp.summary,
policy: localApp.policy,
policy: appForPolicy.policy,
deployment_message: message,
custom_path: localApp.custom_path,
},
@@ -162,16 +368,6 @@ export async function pushRawApp(
css,
},
});
// await wmill.createApp({
// workspace,
// requestBody: {
// path: remotePath,
// deployment_message: message,
// value: { runnables: localApp.runnables, files },
// summary: localApp.summary,
// policy: localApp.policy,
// },
// });
}
}
+5 -7
View File
@@ -262,15 +262,13 @@ async function initAction(opts: InitOptions) {
await Deno.writeTextFile(
"CLAUDE.md",
`
# Claude
You are a helpful assistant that can help with Windmill scripts and flows creation.
You are a helpful assistant that can help with Windmill scripts and flows creation.
## Script Guidance
${scriptGuidanceContent}
## Script Guidance
${scriptGuidanceContent}
## Flow Guidance
${flowGuidanceContent}
## Flow Guidance
${flowGuidanceContent}
`
);
log.info(colors.green("Created CLAUDE.md"));
+12
View File
@@ -66,6 +66,17 @@ export interface ScriptFile {
kind?: "script" | "failure" | "trigger" | "command" | "approval";
}
/**
* Checks if a path is inside a raw app backend folder.
* Matches patterns like: .../myApp.raw_app/backend/...
*/
export function isRawAppBackendPath(filePath: string): boolean {
// Normalize path separators for consistent matching
const normalizedPath = filePath.replaceAll(SEP, "/");
// Check if path contains pattern: *.raw_app/backend/
return /\.raw_app\/backend\//.test(normalizedPath);
}
type PushOptions = GlobalOptions;
async function push(opts: PushOptions, filePath: string) {
opts = await mergeConfigWithConfigFile(opts);
@@ -199,6 +210,7 @@ export async function handleFile(
): Promise<boolean> {
if (
!path.includes(".inline_script.") &&
!isRawAppBackendPath(path) &&
exts.some((exts) => path.endsWith(exts))
) {
if (alreadySynced.includes(path)) {
+56 -15
View File
@@ -70,6 +70,7 @@ import { OpenFlow } from "../../../gen/types.gen.ts";
import { pushResource } from "../resource/resource.ts";
import {
newPathAssigner,
newRawAppPathAssigner,
PathAssigner,
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
@@ -284,10 +285,6 @@ function extractFields(fields: Record<string, any>) {
fields[k] = undefined;
}
}
// if (k == 'runType') {
// fields["type"] = undefined
// fields["schema"] = undefined
// }
});
}
@@ -311,11 +308,7 @@ export function extractInlineScriptsForApps(
}
if (typeof rec == "object") {
return Object.entries(rec).flatMap(([k, v]) => {
if (k == "runType") {
rec["type"] = undefined;
rec["schema"] = undefined;
return [];
} else if (k == "inlineScript" && typeof v == "object") {
if (k == "inlineScript" && typeof v == "object") {
rec["type"] = undefined;
const o: Record<string, any> = v as any;
const name = toId(key ?? "", rec);
@@ -509,13 +502,14 @@ function ZipFSElement(
rawApp.policy = undefined;
let inlineScripts;
const value = rawApp?.["value"];
const runnables = value?.["runnables"] ?? {};
// console.log("FOOB", value?.["runnables"])
extractFieldsForRawApps(value?.["runnables"]);
extractFieldsForRawApps(runnables);
try {
inlineScripts = extractInlineScriptsForApps(
undefined,
value,
newPathAssigner(defaultTs),
newRawAppPathAssigner(defaultTs),
(key, val_) => key
);
} catch (error) {
@@ -549,6 +543,7 @@ function ZipFSElement(
throw error;
}
// Yield inline script content and lock files
for (const s of inlineScripts) {
yield {
isDirectory: false,
@@ -561,12 +556,58 @@ function ZipFSElement(
};
}
const runnables = value?.["runnables"];
if (runnables) {
rawApp.runnables = runnables;
delete rawApp?.["value"];
// Yield each runnable as a separate YAML file in the backend folder
// For inline scripts, simplify the YAML - inlineScript is not needed since
// content/lock/language can be derived from sibling files
for (const [runnableId, runnable] of Object.entries(runnables)) {
const runnableObj = runnable as Record<string, any>;
let simplifiedRunnable: Record<string, any>;
if (runnableObj.inlineScript) {
// For inline scripts, remove inlineScript and just keep type: 'inline'
// plus any other metadata (name, fields, etc.)
simplifiedRunnable = { type: "inline" };
// Copy over any other fields that aren't inlineScript or type
for (const [key, value] of Object.entries(runnableObj)) {
if (key !== "inlineScript" && key !== "type") {
simplifiedRunnable[key] = value;
}
}
} else if (runnableObj.type === "path" && runnableObj.runType) {
// For path-based runnables, convert from API format to file format
// { type: "path", runType: "script" } -> { type: "script" }
// Also remove schema field
const { type: _type, runType, schema: _schema, ...rest } =
runnableObj;
simplifiedRunnable = {
type: runType,
...rest,
};
} else {
// For other runnables, keep as-is
simplifiedRunnable = runnableObj;
}
yield {
isDirectory: false,
path: path.join(
finalPath,
APP_BACKEND_FOLDER,
`${runnableId}.yaml`
),
async *getChildren() {},
// deno-lint-ignore require-await
async getContentText() {
return yamlStringify(simplifiedRunnable, yamlOptions);
},
};
}
// Remove runnables and value from raw_app.yaml - they are now in separate files
delete rawApp?.["value"];
// Don't include runnables in raw_app.yaml anymore
yield {
isDirectory: false,
path: path.join(finalPath, "raw_app.yaml"),
+7 -8
View File
@@ -18,7 +18,10 @@ import {
removeType,
TRIGGER_TYPES,
} from "../../types.ts";
import { fromBranchSpecificPath, isBranchSpecificFile } from "../../core/specific_items.ts";
import {
fromBranchSpecificPath,
isBranchSpecificFile,
} from "../../core/specific_items.ts";
import { getCurrentGitBranch } from "../../utils/git.ts";
import { requireLogin } from "../../core/auth.ts";
import { validatePath, resolveWorkspace } from "../../core/context.ts";
@@ -32,7 +35,7 @@ type Trigger = {
mqtt: MqttTrigger;
sqs: SqsTrigger;
gcp: GcpTrigger;
email: EmailTrigger
email: EmailTrigger;
};
type TriggerFile<K extends TriggerType> = Omit<
@@ -95,9 +98,7 @@ async function updateTrigger<K extends TriggerType>(
postgres: wmill.updatePostgresTrigger,
mqtt: wmill.updateMqttTrigger,
sqs: wmill.updateSqsTrigger,
gcp: async (args) => {
throw new Error("GCP triggers are not supported yet");
},
gcp: wmill.updateGcpTrigger,
email: wmill.updateEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
@@ -124,9 +125,7 @@ async function createTrigger<K extends TriggerType>(
postgres: wmill.createPostgresTrigger,
mqtt: wmill.createMqttTrigger,
sqs: wmill.createSqsTrigger,
gcp: async (args) => {
throw new Error("GCP triggers are not supported yet");
},
gcp: wmill.createGcpTrigger,
email: wmill.createEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
+1 -1
View File
@@ -70,7 +70,7 @@ export {
// }
// });
export const VERSION = "1.589.1";
export const VERSION = "1.589.3";
export const WM_FORK_PREFIX = "wm-fork";
@@ -40,13 +40,13 @@ export const LANGUAGE_EXTENSIONS: Record<SupportedLanguage, string> = {
/**
* Gets the appropriate file extension for a given programming language.
* Handles special cases for TypeScript variants based on the default runtime.
*
*
* @param language - The programming language to get extension for
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
* @returns File extension string (without the dot)
*/
export function getLanguageExtension(
language: SupportedLanguage,
language: SupportedLanguage,
defaultTs: "bun" | "deno" = "bun"
): string {
if (language === defaultTs || language === "bunnative") {
@@ -55,13 +55,67 @@ export function getLanguageExtension(
return LANGUAGE_EXTENSIONS[language] || "no_ext";
}
/**
* Reverse mapping from file extensions to languages.
* Used when deriving language from file extension.
*/
export const EXTENSION_TO_LANGUAGE: Record<string, SupportedLanguage> = {
"py": "python3",
"bun.ts": "bun",
"deno.ts": "deno",
"go": "go",
"sh": "bash",
"ps1": "powershell",
"pg.sql": "postgresql",
"my.sql": "mysql",
"bq.sql": "bigquery",
"odb.sql": "oracledb",
"sf.sql": "snowflake",
"ms.sql": "mssql",
"gql": "graphql",
"native.ts": "nativets",
"frontend.js": "frontend",
"php": "php",
"rs": "rust",
"cs": "csharp",
"nu": "nu",
"playbook.yml": "ansible",
"java": "java",
"duckdb.sql": "duckdb",
// Plain .ts defaults to bun (will be overridden by defaultTs setting)
"ts": "bun",
};
/**
* Gets the language from a file extension.
*
* @param ext - File extension (e.g., "py", "ts", "bun.ts")
* @param defaultTs - Default TypeScript runtime for plain .ts files
* @returns The language, or undefined if not recognized
*/
export function getLanguageFromExtension(
ext: string,
defaultTs: "bun" | "deno" = "bun"
): SupportedLanguage | undefined {
// Check for compound extensions first (e.g., "bun.ts", "pg.sql")
const lang = EXTENSION_TO_LANGUAGE[ext];
if (lang) {
// For plain .ts, return the default TypeScript runtime
if (ext === "ts") {
return defaultTs;
}
return lang;
}
return undefined;
}
export interface PathAssigner {
assignPath(summary: string | undefined, language: SupportedLanguage): [string, string];
}
/**
* Creates a new path assigner for inline scripts.
*
*
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
* @returns Path assigner function
*/
@@ -94,4 +148,43 @@ export function newPathAssigner(defaultTs: "bun" | "deno"): PathAssigner {
return [`${name}.inline_script.`, ext];
}
return { assignPath };
}
/**
* Creates a new path assigner for raw app runnables.
* Unlike newPathAssigner, this does NOT add ".inline_script." prefix since
* everything in raw_app/backend/ is already known to be for inline scripts.
*
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
* @returns Path assigner function
*/
export function newRawAppPathAssigner(defaultTs: "bun" | "deno"): PathAssigner {
let counter = 0;
const seen_names = new Set<string>();
function assignPath(
summary: string | undefined,
language: SupportedLanguage
): [string, string] {
let name;
name = summary?.toLowerCase()?.replaceAll(" ", "_") ?? "";
let original_name = name;
if (name == "") {
original_name = "runnable";
name = `runnable_0`;
}
while (seen_names.has(name)) {
counter++;
name = `${original_name}_${counter}`;
}
seen_names.add(name);
const ext = getLanguageExtension(language, defaultTs);
return [`${name}.`, ext];
}
return { assignPath };
}
+4
View File
@@ -0,0 +1,4 @@
{
"url": "https://context7.com/windmill-labs/windmill",
"public_key": "pk_vV2JZHHPk7T5vJ33aWRkH"
}
+2 -1
View File
@@ -12,4 +12,5 @@ storageState.json
dist/
static/tsdocs/
static/ui_builder/
ui_builder.tar.gz
ui_builder.tar.gz
ui_builder_serve/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.589.1",
"version": "1.589.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.589.1",
"version": "1.589.3",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.589.1",
"version": "1.589.3",
"scripts": {
"dev": "vite dev",
"build": "vite build",
+17
View File
@@ -0,0 +1,17 @@
node ./scripts/untar_ui_builder.js
mkdir ui_builder_serve || true
cp -r static/ui_builder ui_builder_serve/ui_builder || true
rm -rf static/ui_builder || true
python3 -c "
import os
os.chdir('ui_builder_serve')
from http.server import HTTPServer, SimpleHTTPRequestHandler
class H(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
self.send_header('Cross-Origin-Resource-Policy', 'cross-origin')
super().end_headers()
HTTPServer(('', 4000), H).serve_forever()
"
@@ -5,7 +5,7 @@ import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
const VERSION = '1.0.10'
const VERSION = '1.0.11'
export default defineConfig({
build: {
@@ -312,7 +312,8 @@
path: resourceType
})
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
const newArgsKeys = Object.keys(props) ?? []
const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? []
const passwords = newArgsKeys.filter((x) => {
return props?.[x]?.password
})
@@ -8,8 +8,16 @@
import { superadmin, devopsRole } from '$lib/stores'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
import { CUSTOM_TAGS_SETTING } from '$lib/consts'
import { base } from '$lib/base'
import { createEventDispatcher } from 'svelte'
import TextInput from './text_input/TextInput.svelte'
import { twMerge } from 'tailwind-merge'
import Badge from './common/badge/Badge.svelte'
interface Props {
variant?: 'popover' | 'drawer'
}
let { variant = 'popover' }: Props = $props()
let newTag: string = $state('')
let customTags: string[] | undefined = $state(undefined)
@@ -58,16 +66,48 @@
})
loadCustomTags()
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && newTag.trim() !== '' && tagEditor) {
e.stopPropagation()
e.preventDefault()
saveCustomTag(newTag)
}
}
async function saveCustomTag(tag: string, restoreCustomTags: boolean = false) {
try {
await SettingService.setGlobal({
key: CUSTOM_TAGS_SETTING,
requestBody: { value: [...(customTags ?? []), tag.trim().replaceAll(' ', '_')] }
})
dispatch('refresh')
loadCustomTags()
sendUserToast(restoreCustomTags ? 'Tag restored' : 'Tag added')
if (!restoreCustomTags) {
newTag = ''
}
} catch (err) {
sendUserToast(`Could not ${restoreCustomTags ? 'restore' : 'save'} custom tag: ${err}`, true)
}
}
</script>
<div class="flex flex-col w-72 p-4 gap-2">
<svelte:window onkeydown={onKeyDown} />
<div
class="flex flex-col gap-2"
class:w-72={variant === 'popover'}
class:p-4={variant === 'popover'}
>
{#if customTags == undefined}
<Loader2 class="animate-spin" />
{:else}
<div class="flex flex-col gap-y-1">
<div class="flex flex-row flex-wrap gap-y-1 gap-x-2">
{#each customTags as customTag}
<div class="flex gap-0.5 items-center"
><div class="text-2xs p-1 rounded border text-primary">{customTag}</div>
<Badge color="blue">
{customTag}
{#if tagEditor}
<button
class="z-10 rounded-full p-1 duration-200 hover:bg-gray-200"
@@ -80,7 +120,14 @@
})
dispatch('refresh')
loadCustomTags()
sendUserToast('Tag removed')
sendUserToast('Tag removed', false, [
{
label: 'Undo',
callback: () => {
saveCustomTag(customTag, true)
}
}
])
})
)}
>
@@ -88,12 +135,26 @@
</button>
{/if}
<NoWorkerWithTagWarning tag={customTag} />
</div>
</Badge>
{/each}
</div>
<input type="text" bind:value={newTag} />
<div class={twMerge('w-full flex gap-2', variant === 'popover' ? 'flex-col ' : 'flex-row ')}>
<TextInput bind:value={newTag} />
<Button
variant="accent"
unifiedSize="md"
onClick={() => saveCustomTag(newTag)}
disabled={newTag.trim() == '' || !tagEditor}
wrapperClasses="min-w-24"
>
Add custom tag {#if !tagEditor}
<span class="text-2xs text-primary">superadmin or devops only</span>
{/if}
</Button>
</div>
{#if extractedCustomTag}
<div class="text-2xs text-primary p-2 bg-surface-secondary rounded border">
<div class="text-2xs text-primary p-2 bg-surface-secondary rounded">
<div class="font-medium mb-1">Workspace specific tag</div>
<div>
<b>Tag:</b>
@@ -140,55 +201,40 @@
{/if}
{/if}
<Button
variant="accent"
size="sm"
on:click={async () => {
await SettingService.setGlobal({
key: CUSTOM_TAGS_SETTING,
requestBody: {
value: [...(customTags ?? []), newTag.trim().replaceAll(' ', '_')]
}
})
dispatch('refresh')
loadCustomTags()
sendUserToast('Tag added')
}}
disabled={newTag.trim() == '' || !tagEditor}
>
Add {#if !tagEditor}
<span class="text-2xs text-primary">superadmin or devops only</span>
<span class="text-2xs text-secondary leading-relaxed">
{#if variant !== 'drawer'}
Configure <a
href="https://www.windmill.dev/docs/core_concepts/worker_groups"
target="_blank"
class="inline-flex gap-1 items-baseline"
>worker groups <ExternalLink size={12} />
</a>
to listen to tags.
<br />
{/if}
</Button>
<span class="text-sm text-primary"
>Configure <a href="{base}/workers" target="_blank" class="inline-flex gap-1 items-baseline"
>worker groups <ExternalLink size={12} /></a
> to listen to tags</span
>
<span class="text-2xs text-primary"
>For tags specific to some workspaces, use <pre class="inline">tag(workspace1+workspace2)</pre
></span
>
<span class="text-2xs text-primary"
>To exclude 'workspace1' and 'workspace2' from a tag, use <pre class="inline"
>tag(^workspace1^workspace2)</pre
></span
>
<span class="text-2xs text-primary"
>For <a
For tags specific to some workspaces, use
<pre class="inline text-emphasis">tag(workspace1+workspace2)</pre>
<br />{#if variant !== 'drawer'}<br />{/if}
To exclude 'workspace1' and 'workspace2' from a tag, use
<pre class="inline text-emphasis">tag(^workspace1^workspace2)</pre>
<br />{#if variant !== 'drawer'}<br />{/if}
For
<a
href="https://www.windmill.dev/docs/core_concepts/worker_groups#dynamic-tag"
target="_blank">dynamic tags</a
target="_blank">dynamic tags <ExternalLink size={12} class="inline-block" /></a
>
based on the workspace, use <pre class="inline">$workspace</pre>, e.g:
<pre class="inline">tag-$workspace</pre></span
>
<span class="text-2xs text-primary"
>For <a
based on the workspace, use <pre class="inline text-emphasis">$workspace</pre>, e.g:
<pre class="inline text-emphasis">tag-$workspace</pre><br />
{#if variant !== 'drawer'}<br />{/if}
For
<a
href="https://www.windmill.dev/docs/core_concepts/worker_groups#dynamic-tag"
target="_blank">dynamic tags</a
target="_blank">dynamic tags <ExternalLink size={12} class="inline-block" /></a
>
based on args input, use <pre class="inline">$args[a.b.c]</pre> where
<pre class="inline">a.b.c</pre> is the path to the value in the args object</span
>
based on args input, use <pre class="inline text-emphasis">$args[a.b.c]</pre> where
<pre class="inline">a.b.c</pre> is the path to the value in the args object.
</span>
{/if}
</div>
@@ -5,26 +5,35 @@
import Tooltip from './Tooltip.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Button } from './common'
import { Alert, Button } from './common'
import { ExternalLink } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import TextInput from './text_input/TextInput.svelte'
import Label from './Label.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
import { ConfigService } from '$lib/gen'
import Select from './select/Select.svelte'
import ScriptPicker from './ScriptPicker.svelte'
import Badge from './common/badge/Badge.svelte'
interface Props {
config: AutoscalingConfig | undefined
worker_tags: string[] | undefined
disabled: boolean
}
let { config = $bindable(), worker_tags }: Props = $props()
const dispatch = createEventDispatcher()
let { config = $bindable(), worker_tags, disabled }: Props = $props()
let test_input: number = $state(3)
let healthCheckLoading: boolean = $state(false)
let healthCheckResult: { success: boolean; error?: string } | null = $state(null)
function validateMinMax(): string | undefined {
if (config?.min_workers && config?.max_workers && config.min_workers > config.max_workers) {
return 'Minimum cannot be greater than maximum'
}
return undefined
}
async function checkKubernetesHealth() {
if (!config?.integration || config.integration.type !== 'kubernetes') return
@@ -43,366 +52,258 @@
healthCheckLoading = false
}
}
let collapsed: boolean = $state(true)
</script>
<div class="flex flex-row gap-16 pt-2">
<div class="space-y-4 flex flex-col gap-1 max-w-xs text-sm">
<h5>Rules</h5>
<Toggle
checked={config?.enabled ?? false}
options={{ right: 'Enabled' }}
on:change={(e) => {
dispatch('dirty')
if (e.detail) {
if (!config) {
config = {
enabled: true,
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
<Section
label="Autoscaling"
collapsable
class="flex flex-col gap-6"
bind:collapsed
description="Autoscaling automatically adjusts the number of workers based on your workload demands."
>
{#snippet labelExtra()}
<Badge color="gray">Beta</Badge>
{/snippet}
{#snippet header()}
<div class="ml-2">
<Toggle
checked={config?.enabled ?? false}
options={{ right: 'Enabled' }}
{disabled}
on:change={(e) => {
if (e.detail) {
collapsed = false
if (!config) {
config = {
enabled: true,
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}
} else {
config.enabled = true
}
} else {
config.enabled = true
config = {
...(config ?? {
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}),
enabled: false
}
}
} else {
config = {
...(config ?? {
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}),
enabled: false
}
}
}}
/>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Min # of Workers
}}
/>
</div>
{/snippet}
<div class="flex flex-row gap-4">
<Label label="Min # of workers" disabled={config === undefined} class="grow min-w-0">
<span class="text-xs text-secondary">The minimum number of workers to scale down to</span>
{#if config !== undefined}
<input oninput={() => dispatch('dirty')} type="number" bind:value={config.min_workers} />
{#if config.min_workers !== undefined && config.min_workers != undefined && config.min_workers > config.max_workers}
<div class="text-red-600 text-xs whitespace-nowrap"
>Minimum cannot be {'>'} to Maximum</div
>
<input
type="number"
min="1"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50 disabled:bg-surface-disabled disabled:border-transparent disabled:text-disabled"
bind:value={config.min_workers}
{disabled}
/>
{#if validateMinMax()}
<div class="text-2xs text-red-500 font-normal mt-1">
{validateMinMax()}
</div>
{/if}
{:else}
<input type="number" disabled />
<input type="number" {disabled} placeholder="3" />
{/if}
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Max # of Workers
</Label>
<Label label="Max # of workers" disabled={config === undefined} class="grow min-w-0">
<span class="text-xs text-secondary">The maximum number of workers to scale up to</span>
{#if config !== undefined}
<input oninput={() => dispatch('dirty')} type="number" bind:value={config.max_workers} />
<input
type="number"
min="1"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50 disabled:bg-surface-disabled disabled:border-transparent disabled:text-disabled"
bind:value={config.max_workers}
{disabled}
/>
{:else}
<input type="number" disabled />
<input type="number" disabled placeholder="10" />
{/if}
</label>
<div class="p-2">
<Section label="Advanced" small collapsable={true}>
<div class="flex flex-col gap-2 text-2xs">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Cooldown seconds after an incremental scale-in/out
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
step="1"
min="30"
placeholder="300"
bind:value={config.cooldown_seconds}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Cooldown seconds after a full scale out
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
step="1"
min="30"
placeholder="1500"
bind:value={config.full_scale_cooldown_seconds}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Num jobs waiting to trigger an incremental scale-out
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
bind:value={config.inc_scale_num_jobs_waiting}
placeholder="1"
/>
{:else}
<input type="number" disabled />
{/if}
</label>
</Label>
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Num jobs waiting to trigger a full scale out <Tooltip
>Default: max_workers, full scale out = scale out to max workers</Tooltip
>
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
placeholder="max workers"
bind:value={config.full_scale_jobs_waiting}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Occupancy rate % threshold to go below to trigger a scale-in (decrease) <Tooltip
>Default: 25%, need to go below average of all of 15s, 5m and 30m occupancy rates</Tooltip
>
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
step="1"
min="0"
max="100"
placeholder="25"
bind:value={config.dec_scale_occupancy_rate}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Occupancy rate threshold to exceed to trigger an incremental scale-out (increase) <Tooltip
>Default: 75%, need to exceed average of all of 15s, 5m and 30m occupancy rates</Tooltip
>
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
step="1"
min="0"
max="100"
placeholder="75"
bind:value={config.inc_scale_occupancy_rate}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
Num workers to scale-in/out by when incremental <Tooltip
>Default: (max_workers - min_workers) / 5</Tooltip
>
{#if config !== undefined}
<input
oninput={() => dispatch('dirty')}
type="number"
step="1"
min="1"
placeholder="(max_workers - min_workers) / 5"
bind:value={config.inc_num_workers}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<Label label="Integration">
<span class="text-xs text-secondary">Choose how to autoscale your worker group</span>
{#if config?.integration}
<div class="flex flex-col gap-2">
<ToggleButtonGroup bind:selected={config.integration.type} {disabled}>
{#snippet children({ item })}
<ToggleButton
value="dryrun"
label="Dry run"
tooltip="See autoscaling events but not actual scaling actions will be performed"
{item}
/>
<ToggleButton
value="script"
label="Custom script"
tooltip="Run a custom script to scale your worker group"
{item}
/>
<ToggleButton disabled value="ecs" label="ECS (soon)" {item} />
<ToggleButton disabled value="nomad" label="Nomad (soon)" {item} />
<ToggleButton value="kubernetes" label="Kubernetes" {item} />
{/snippet}
</ToggleButtonGroup>
<Label label="Custom tags to autoscale on">
{#snippet header()}
<Tooltip>
By default, autoscaling will apply to the tags the worker group is assigned to but
you can override this here.
</Tooltip>
{/snippet}
{#if config}
{#if config.custom_tags}
<MultiSelect
bind:value={
() => config?.custom_tags ?? [],
{#if config.integration.type === 'script'}
<div class="flex flex-col gap-6 p-4 rounded-md border border-border-light">
<Label label="Script path" required>
<div class="flex flex-row gap-2">
<ScriptPicker
itemKind="script"
bind:scriptPath={
() => config?.integration?.['path'] ?? undefined,
(v) => {
config && (config.custom_tags = v.length ? v : undefined)
dispatch('dirty')
if (!config || !config.integration) return
if (!v || v === '') {
delete config.integration['path']
} else {
config.integration['path'] = v
}
}
}
items={safeSelectItems(worker_tags)}
placeholder="Tags"
clearable
{disabled}
/>
{:else}
<Button
color="light"
size="xs"
variant="contained"
on:click={() => {
if (config) {
config.custom_tags = []
dispatch('dirty')
}
}}>Add custom tags</Button
>
{/if}
{/if}
</Label>
</div>
</Section>
</div>
</div>
<div class="flex flex-col gap-1 max-w-xs text-sm">
<h5>Integration</h5>
{#if config?.integration}
<ToggleButtonGroup
on:selected={(e) => dispatch('dirty')}
bind:selected={config.integration.type}
class="mb-4 mt-2"
>
{#snippet children({ item })}
<ToggleButton
value="dryrun"
label="Dry run"
tooltip="See autoscaling events but not actual scaling actions will be performed"
{item}
/>
<ToggleButton
value="script"
label="Custom script"
tooltip="Run a custom script to scale your worker group"
{item}
/>
<ToggleButton disabled value="ecs" label="ECS (soon)" {item} />
<ToggleButton disabled value="nomad" label="Nomad (soon)" {item} />
<ToggleButton value="kubernetes" label="Kubernetes" {item} />
{/snippet}
</ToggleButtonGroup>
{#if config.integration.type === 'script'}
<label>
Script path on the 'admins' workspace
<input
oninput={() => dispatch('dirty')}
type="text"
bind:value={config.integration.path}
/>
</label>
<label>
Custom tag for executing script (optional)
{#if config.integration.tag}
<input
oninput={() => dispatch('dirty')}
type="text"
bind:value={config.integration.tag}
/>
{:else}
<Button
color="light"
size="xs"
variant="contained"
on:click={() => {
if (config?.integration?.type === 'script') {
config.integration.tag = 'bash'
dispatch('dirty')
}
}}>Set tag</Button
>
{/if}
</label>
<div class="flex mt-6 gap-2">
<Button
variant="accent"
target="_blank"
endIcon={{ icon: ExternalLink }}
href="/scripts/add?hub=hub%2F9204%2Fhelper%2FScale%20a%20worker%20group%20deployed%20as%20a%20kubernetes%20service&workspace=admins"
>Create from template</Button
>
<Button
variant="accent"
target="_blank"
href={`/runs/${config.integration.path}?workspace=admins`}
endIcon={{ icon: ExternalLink }}
>
See jobs
</Button>
</div>
<div class="flex flex-row gap-2 mt-4">
<Button color="light" size="xs" variant="contained">Test scaling</Button>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
{/if}
{#if config.integration.type === 'kubernetes'}
<div class="text-sm text-secondary mb-3">
Kubernetes configuration is automatically inferred from the cluster environment. The
worker group name and namespace will be detected automatically.
</div>
<div class="flex flex-col gap-3 mt-4">
<div class="flex items-center gap-2">
<Button
size="xs"
variant="accent"
startIcon={{ icon: ExternalLink }}
href="https://windmill.dev/docs/core_concepts/autoscaling#kubernetes"
target="_blank"
>
Setup Guide (Roles & Bindings)
</Button>
<Button
color="light"
size="xs"
variant="contained"
onclick={checkKubernetesHealth}
disabled={healthCheckLoading}
>
{healthCheckLoading ? 'Checking...' : 'Check Health'}
</Button>
</div>
{#if healthCheckResult !== null}
<div
class="p-2 rounded-md text-sm {healthCheckResult.success
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'}"
>
{#if healthCheckResult.success}
Kubernetes autoscaling is healthy
{:else}
{healthCheckResult.error}
{#if healthCheckResult.error?.includes('permissions') || healthCheckResult.error?.includes('role')}
<br /><small
>Please follow the setup guide above to configure proper RBAC permissions.</small
{#if config?.integration?.['path'] === undefined || config?.integration?.['path'] === ''}
<Button
variant="default"
target="_blank"
endIcon={{ icon: ExternalLink }}
href="/scripts/add?hub=hub%2F9204%2Fhelper%2FScale%20a%20worker%20group%20deployed%20as%20a%20kubernetes%20service&workspace=admins"
>Create from template
{disabled}
</Button>
{:else}
<Button
variant="default"
target="_blank"
href={`/runs/${config.integration.path}?workspace=admins`}
endIcon={{ icon: ExternalLink }}
{disabled}
>
See jobs
</Button>
{/if}
</div>
<span class="text-2xs text-hint">Script must be in the 'admins' workspace</span>
</Label>
<Label
label="Custom tag for executing script"
tooltip="Optional tag to specify worker capabilities required for this script"
for="custom_tag_select"
>
<Select
clearable
id="custom_tag_select"
disabled={!config || !config.integration || disabled}
bind:value={
() => config?.integration?.['tags'] ?? undefined,
(v) => {
if (!config || !config.integration) return
if (!v || v === '') {
delete config.integration['tags']
} else {
config.integration['tags'] = v
}
}
}
items={safeSelectItems(worker_tags)}
/>
<div class="flex flex-row gap-2 justify-end mt-4">
<Button variant="default" unifiedSize="md">Test scaling</Button>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
</Label>
</div>
{:else if config.integration.type === 'kubernetes'}
<div class="flex flex-col gap-3 p-4 border border-border-light rounded-md">
<div class="text-xs text-secondary mb-2">
Kubernetes configuration is automatically inferred from the cluster environment. The
worker group name and namespace will be detected automatically.
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 justify-between">
<div class="flex flex-row gap-2">
<Button
unifiedSize="md"
variant="default"
endIcon={{ icon: ExternalLink }}
href="https://windmill.dev/docs/core_concepts/autoscaling#kubernetes"
target="_blank"
>
Setup Guide (Roles & Bindings)
</Button>
<Button
unifiedSize="md"
variant="default"
onclick={checkKubernetesHealth}
disabled={healthCheckLoading}
>
{healthCheckLoading ? 'Checking...' : 'Check Health'}
</Button>
</div>
<div class="flex flex-row gap-2 justify-end">
<Button unifiedSize="md" variant="default">Test scaling</Button>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
</div>
{#if healthCheckResult !== null}
<Alert
type={healthCheckResult.success ? 'success' : 'error'}
title={healthCheckResult.success ? 'Health check passed' : 'Health check failed'}
>
{#if healthCheckResult.success}
Kubernetes autoscaling is healthy
{:else}
{healthCheckResult.error}
{#if healthCheckResult.error?.includes('permissions') || healthCheckResult.error?.includes('role')}
<br /><small
>Please follow the setup guide above to configure proper RBAC permissions.</small
>
{/if}
{/if}
</Alert>
{/if}
</div>
{/if}
<div class="flex flex-row gap-2">
<Button color="light" size="xs" variant="contained">Test scaling</Button>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
</div>
{/if}
{:else if config.integration.type === 'dryrun'}
<div class="p-4 border border-border-light rounded-md">
<span class="text-xs text-secondary">
In dry run mode, autoscaling will be simulated and events will be logged but no actual
scaling will be performed.
</span>
</div>
{/if}
</div>
{:else}
<ToggleButtonGroup selected={'script'} disabled class="mb-4 mt-2">
{#snippet children({ item })}
@@ -414,10 +315,186 @@
{/snippet}
</ToggleButtonGroup>
<label>
Script path on the 'admins' workspace
<input type="text" disabled />
</label>
<Label label="Script path on the 'admins' workspace" for="script_path">
<TextInput
inputProps={{
disabled: true,
id: 'script_path',
placeholder: 'e.g. f/scaling/scale_worker_group'
}}
/>
</Label>
{/if}
</div>
</div>
</Label>
<Section label="Advanced" small collapsable={true} class="flex flex-col gap-6">
<Label
label="Cooldown seconds after incremental scale-in/out"
disabled={config === undefined || disabled}
tooltip="Time to wait between incremental scaling operations"
>
{#if config !== undefined}
<input
type="number"
step="1"
min="30"
placeholder="300"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50"
bind:value={config.cooldown_seconds}
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label
label="Cooldown seconds after full scale out"
disabled={config === undefined || disabled}
tooltip="Time to wait after scaling to maximum capacity"
>
{#if config !== undefined}
<input
type="number"
step="1"
min="30"
placeholder="1500"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50"
bind:value={config.full_scale_cooldown_seconds}
{disabled}
/>
{:else}
<input
type="number"
disabled
class="rounded-md border border-border-light text-xs font-normal bg-surface-disabled border-transparent text-disabled px-2 py-1"
/>
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label label="Num jobs waiting to trigger an incremental scale-out">
{#if config !== undefined}
<input
type="number"
bind:value={config.inc_scale_num_jobs_waiting}
placeholder="1"
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label
label="Num jobs waiting to trigger a full scale out"
tooltip="Default: max_workers, full scale out = scale out to max workers"
for="full_scale_jobs_waiting"
>
{#if config !== undefined}
<input
type="number"
placeholder="max workers"
bind:value={config.full_scale_jobs_waiting}
id="full_scale_jobs_waiting"
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<div class="flex flex-row gap-4">
<Label
label="Min occupancy rate"
tooltip="Default: 25%, need to go below average of all of 15s, 5m and 30m occupancy rates"
for="occupancy_rate_min"
class="grow min-w-0"
>
<span class="text-xs text-secondary"
>{`Threshold (%) to go below to trigger a scale-in (decrease)`}</span
>
{#if config !== undefined}
<input
type="number"
step="1"
min="0"
max="100"
placeholder="25"
id="occupancy_rate_min"
bind:value={config.dec_scale_occupancy_rate}
{disabled}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label
label="Max occupancy rate"
tooltip="Default: 75%, need to exceed average of all of 15s, 5m and 30m occupancy rates"
for="occupancy_rate_max"
class="grow min-w-0"
>
<span class="text-xs text-secondary"
>{`Threshold (%) to exceed to trigger a scale-out (increase)`}</span
>
{#if config !== undefined}
<input
type="number"
step="1"
min="0"
max="100"
placeholder="75"
id="occupancy_rate_max"
bind:value={config.inc_scale_occupancy_rate}
{disabled}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</Label>
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label
label="Num workers to scale-in/out by when incremental"
tooltip="Default: (max_workers - min_workers) / 5"
>
{#if config !== undefined}
<input
type="number"
step="1"
min="1"
placeholder="(max_workers - min_workers) / 5"
bind:value={config.inc_num_workers}
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label label="Custom tags to autoscale on" for="multi_select_custom_tags">
{#snippet header()}
<Tooltip>
By default, autoscaling will apply to the tags the worker group is assigned to but you can
override this here.
</Tooltip>
{/snippet}
{#if config}
<MultiSelect
id="multi_select_custom_tags"
bind:value={
() => config?.custom_tags ?? [],
(v) => {
config && (config.custom_tags = v.length ? v : undefined)
}
}
items={safeSelectItems(worker_tags)}
placeholder="Tags"
{disabled}
/>
{/if}
</Label>
</Section>
</Section>
@@ -1,11 +1,14 @@
<script lang="ts">
import { ConfigService, type AutoscalingEvent } from '$lib/gen'
import { LoaderIcon, RefreshCw } from 'lucide-svelte'
import { RefreshCw } from 'lucide-svelte'
import { Button, Skeleton } from './common'
import { twMerge } from 'tailwind-merge'
import TimeAgo from './TimeAgo.svelte'
import { enterpriseLicense } from '$lib/stores'
import { untrack } from 'svelte'
import DataTable from './table/DataTable.svelte'
import Head from './table/Head.svelte'
import Cell from './table/Cell.svelte'
interface Props {
worker_group: string
@@ -37,55 +40,74 @@
})
</script>
<div>
<h6
class={!$enterpriseLicense || (events != undefined && events.length == 0)
? 'text-xs text-emphasis font-semibold'
: ''}
>Autoscaling events {#if $enterpriseLicense}<span class="text-xs text-primary">(5 last)</span>
<span class="inline-flex ml-6">
<Button
startIcon={{
icon: loading ? LoaderIcon : RefreshCw,
classes: twMerge(
loading ? 'animate-spin text-blue-800' : '',
'transition-all text-gray-500 dark:text-white'
)
}}
color="light"
size="xs2"
btnClasses={twMerge(loading ? ' bg-blue-100 dark:bg-blue-400' : '', 'transition-all')}
on:click={() => loadEvents()}
iconOnly
/>
</span>{/if}
</h6>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-baseline gap-2">
<h3 class="text-xs font-semibold text-emphasis">Autoscaling events</h3>
{#if $enterpriseLicense && events && events.length > 0}
<span class="text-2xs text-secondary">Showing last {Math.min(limit, events.length)}</span>
{/if}
</div>
{#if $enterpriseLicense}
<Button
startIcon={{
icon: RefreshCw,
classes: twMerge(loading ? 'animate-spin' : '')
}}
variant="subtle"
unifiedSize="sm"
on:click={() => loadEvents()}
iconOnly
/>
{/if}
</div>
{#if !$enterpriseLicense}
<div class="text-xs pt-1 text-secondary">Autoscaling is an EE feature</div>
<div class="text-xs font-normal text-secondary">Autoscaling is an EE feature</div>
{:else if loading}
<Skeleton layout={[[12], 1]} />
{:else if events}
{#if events.length == 0}
<div class="text-xs pt-2 text-primary"
>No events, is autoscaling set in the worker group config?</div
>
{:else}
<div class="flex flex-col gap-2 text-xs text-primary pt-4">
{#each events as event}
<div class="flex flex-row gap-4">
<div class="text-primary">{event.event_type} to {event.desired_workers}</div>
<div class="text-secondary">{event.reason}</div>
<div class="text-primary"><TimeAgo date={event.applied_at ?? ''} /></div>
</div>
{/each}
<div class="text-xs font-normal text-secondary">
No events. Is autoscaling configured in the worker group config?
</div>
{:else}
<DataTable size="sm" noBorder={false} rounded={true}>
<Head>
<tr>
<Cell head first>Event type</Cell>
<Cell head>Desired workers</Cell>
<Cell head>Reason</Cell>
<Cell head last>Time</Cell>
</tr>
</Head>
<tbody>
{#each events as event}
<tr class="border-b last:border-b-0">
<Cell first class="text-xs font-normal text-primary">{event.event_type ?? 'N/A'}</Cell
>
<Cell class="text-xs font-normal text-primary">{event.desired_workers}</Cell>
<Cell class="text-xs font-normal text-secondary">{event.reason ?? 'N/A'}</Cell>
<Cell last class="text-xs font-normal text-secondary">
<TimeAgo date={event.applied_at ?? ''} />
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
{#if events.length >= limit && limit < 100}
<div class="flex">
<Button variant="subtle" unifiedSize="sm" on:click={() => (limit = limit + 25)}>
Show more
</Button>
</div>
{/if}
{/if}
<div class="mt-4 flex">
<Button color="light" size="xs2" on:click={() => (limit = limit + 25)}>Show more</Button>
</div>
{#if limit > 50}
<div class="mt-4 flex text-xs text-primary">
Note that autoscaling events are only stored for the last 30 days.
<div class="text-2xs font-normal text-hint">
Note: Autoscaling events are only stored for the last 30 days.
</div>
{/if}
{/if}
@@ -1,9 +1,18 @@
<script>
<script lang="ts">
import { twMerge } from 'tailwind-merge'
interface Props {
class?: string
children?: import('svelte').Snippet<[{ width: number }]>
}
let { class: clazz = '', children }: Props = $props()
let width = $state(0)
</script>
<div class="pb-8">
<div class={twMerge('max-w-7xl mx-auto px-4 sm:px-6 md:px-8', $$restProps.class)}>
<slot />
</div>
<div class={twMerge('max-w-7xl mx-auto px-4 sm:px-6 md:px-8', clazz)} bind:clientWidth={width}
>{@render children?.({ width })}</div
>
</div>
@@ -11,7 +11,7 @@
<div class={twMerge('flex', $$props.class)}>
<Button
variant="default"
variant="subtle"
btnClasses="text-primary {small ? 'text-xs' : ''} "
on:click={() => (open = !open)}
endIcon={{ icon: ChevronDown, classes: open ? 'transform rotate-180' : '' }}
@@ -1,39 +0,0 @@
<script lang="ts">
import { Button } from './common'
import { Pen } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import Popover from './meltComponents/Popover.svelte'
import DefaultTagsInner from './DefaultTagsInner.svelte'
interface Props {
defaultTagPerWorkspace?: boolean | undefined
defaultTagWorkspaces?: string[]
}
let {
defaultTagPerWorkspace = $bindable(undefined),
defaultTagWorkspaces = $bindable([])
}: Props = $props()
let placement: 'bottom-end' | 'top-end' = 'bottom-end'
</script>
<Popover
floatingConfig={{ strategy: 'absolute', placement: placement }}
contentClasses="p-4 max-h-[80vh] overflow-y-auto"
>
{#snippet trigger()}
<Button variant="default" unifiedSize="md" nonCaptureEvent={true}>
<div class="flex flex-row gap-1 items-center"
><Pen size={14} /> Default tags&nbsp;<Tooltip light
>Scripts and steps that have not been specifically assigned tags will use a default tag
that can be customized here</Tooltip
></div
>
</Button>
{/snippet}
{#snippet content()}
<DefaultTagsInner bind:defaultTagPerWorkspace bind:defaultTagWorkspaces />
{/snippet}
</Popover>
@@ -1,21 +1,40 @@
<script lang="ts">
import { Button } from './common'
import { AlertTriangle, Loader2 } from 'lucide-svelte'
import { ExternalLink, Loader2, Save } from 'lucide-svelte'
import { SettingService, WorkerService, WorkspaceService } from '$lib/gen'
import Tooltip from './Tooltip.svelte'
import { sendUserToast } from '$lib/toast'
import { enterpriseLicense, superadmin } from '$lib/stores'
import { DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING } from '$lib/consts'
import Toggle from './Toggle.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
import Badge from './common/badge/Badge.svelte'
import Section from './Section.svelte'
interface Props {
defaultTagPerWorkspace?: boolean | undefined
defaultTagWorkspaces?: string[]
}
let defaultTags: string[] | undefined = undefined
export let defaultTagPerWorkspace: boolean | undefined = undefined
export let defaultTagWorkspaces: string[] = []
let limitToWorkspaces = false
let {
defaultTagPerWorkspace = $bindable(undefined),
defaultTagWorkspaces = $bindable([])
}: Props = $props()
let workspaces: string[] = []
let defaultTags = $state<string[] | undefined>(undefined)
let limitToWorkspaces = $state(false)
// Change detection
let originalDefaultTagPerWorkspace = $state<boolean | undefined>(defaultTagPerWorkspace)
let originalDefaultTagWorkspaces = $state<string[]>(defaultTagWorkspaces)
// Detect changes
let hasChanges = $derived(
originalDefaultTagPerWorkspace !== defaultTagPerWorkspace ||
JSON.stringify($state.snapshot(originalDefaultTagWorkspaces)?.sort() || []) !==
JSON.stringify($state.snapshot(defaultTagWorkspaces)?.sort() || [])
)
let workspaces: string[] = $state([])
async function loadWorkspaces() {
workspaces = (await WorkspaceService.listWorkspacesAsSuperAdmin()).map((m) => m.id)
}
@@ -33,85 +52,122 @@
}
}
async function handleSave() {
await SettingService.setGlobal({
key: DEFAULT_TAGS_PER_WORKSPACE_SETTING,
requestBody: {
value: defaultTagPerWorkspace
}
})
await SettingService.setGlobal({
key: DEFAULT_TAGS_WORKSPACES_SETTING,
requestBody: {
value:
limitToWorkspaces && defaultTagWorkspaces && defaultTagWorkspaces.length > 0
? defaultTagWorkspaces
: undefined
}
})
// Update original state after save
originalDefaultTagPerWorkspace = defaultTagPerWorkspace
originalDefaultTagWorkspaces = [...(defaultTagWorkspaces || [])]
loadDefaultTags()
sendUserToast('Saved')
}
loadDefaultTags()
loadWorkspaces()
</script>
<div class="flex flex-col w-80 p-2 gap-2">
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap justify-end">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
{/if}
<Section label="Default tags">
<div class="text-2xs text-secondary mb-2">
Jobs that have not been specifically assigned custom tags will use a <a
href="https://www.windmill.dev/docs/core_concepts/worker_groups#default-worker-group"
target="_blank"
class="gap-1 items-baseline">default tags <ExternalLink size={12} class="inline-block" /></a
> based on the language they are in or their kind.
</div>
{#snippet action()}
{#if !$enterpriseLicense}
<span class="text-secondary text-xs">Read only</span>
{:else}
<Button
variant="accent"
unifiedSize="md"
on:click={handleSave}
startIcon={{ icon: Save }}
disabled={!hasChanges || !$enterpriseLicense || !$superadmin}
>
Save
</Button>
{/if}
{/snippet}
{#if defaultTagPerWorkspace == undefined || defaultTags == undefined}
<Loader2 class="animate-spin" />
{:else}
<div class="flex flex-col gap-y-1">
{#each defaultTags.sort() as tag (tag)}
<div class="flex gap-2 items-center"
><div class="p-1 text-xs px-2 rounded border text-primary w-32">{tag} </div><div
class="flex gap-2 items-center w-92"
>&rightarrow;
<input
class="text-xs w-full"
disabled
type="text"
value={defaultTagPerWorkspace ? `${tag}-$workspace` : tag}
/></div
>
</div>
{:else if !$enterpriseLicense}
<!-- Tag List -->
<div class="flex gap-y-1 gap-x-2 flex-wrap">
{#each $state.snapshot(defaultTags).sort() as tag (tag)}
<Badge color="blue">{defaultTagPerWorkspace ? `${tag}-$workspace` : tag}</Badge>
{/each}
</div>
{:else}
<!-- Settings -->
<div class="py-4 flex flex-col gap-2">
<Toggle
bind:checked={defaultTagPerWorkspace}
options={{ right: 'workspace specific default tags' }}
/>
<div class="flex flex-col gap-1">
<Toggle
bind:checked={defaultTagPerWorkspace}
options={{
right: 'make default tags workspace specific',
rightTooltip:
'When tags use $workspace, the final tag has $workspace replaced with the workspace id, allowing multi-vpc setup with more ease, without having to assign a specific tag each time.'
}}
class="w-fit"
disabled={!$enterpriseLicense}
/>
</div>
{#if defaultTagPerWorkspace}
<Toggle bind:checked={limitToWorkspaces} options={{ right: 'only for some workspaces' }} />
<Toggle
bind:checked={limitToWorkspaces}
options={{ right: 'only for some workspaces' }}
class="w-fit"
disabled={!$enterpriseLicense}
/>
{#if limitToWorkspaces}
<MultiSelect
disablePortal
disabled={!$enterpriseLicense}
items={safeSelectItems(workspaces)}
bind:value={defaultTagWorkspaces}
/>
{/if}
{/if}
</div>
<Button
variant="accent"
size="sm"
on:click={async () => {
await SettingService.setGlobal({
key: DEFAULT_TAGS_PER_WORKSPACE_SETTING,
requestBody: {
value: defaultTagPerWorkspace
}
})
await SettingService.setGlobal({
key: DEFAULT_TAGS_WORKSPACES_SETTING,
requestBody: {
value:
limitToWorkspaces && defaultTagWorkspaces && defaultTagWorkspaces.length > 0
? defaultTagWorkspaces
: undefined
}
})
loadDefaultTags()
sendUserToast('Saved')
}}
disabled={!$enterpriseLicense || !$superadmin}
>
Save {#if !$superadmin}
<span class="text-2xs text-primary">superadmin only</span>
{/if}
</Button>
<span class="text-2xs text-primary"
>When tags use <pre class="inline">$workspace</pre>, the final tag has
<pre class="inline">$workspace</pre> replaced with the workspace id, allowing multi-vpc setup with
more ease, without having to assign a specific tag each time.</span
>
<div class="flex gap-2 items-center mb-1">
<div class="w-36 text-2xs font-semibold text-secondary">Job language or kind</div>
<div class="w-6 text-2xs font-semibold text-secondary"></div>
<div class="flex-1 text-2xs font-semibold text-secondary">Default tag</div>
</div>
<!-- Tag List -->
<div class="flex gap-y-1 flex-col">
{#each $state.snapshot(defaultTags).sort() as tag (tag)}
<div class="flex gap-2 items-center">
<div class="w-36">
<Badge color="transparent">{tag}</Badge>
</div>
<div class="w-6 flex justify-center text-secondary">&rightarrow;</div>
<div class="flex-1">
<Badge color="blue">{defaultTagPerWorkspace ? `${tag}-$workspace` : tag}</Badge>
</div>
</div>
{/each}
</div>
{/if}
</div>
</Section>
@@ -4,6 +4,7 @@
import { twMerge } from 'tailwind-merge'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import type { Item } from '$lib/utils'
import { Tooltip } from './meltComponents'
interface Props {
aiId?: string
@@ -53,6 +54,13 @@
{item.displayName}
</p>
{@render item.extra?.()}
{#if item.tooltip}
<Tooltip>
{#snippet text()}
{item.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{/each}
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import { Building } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Tooltip } from './meltComponents'
interface Props {
class?: string
children?: import('svelte').Snippet
}
let { class: className = '', children = undefined }: Props = $props()
</script>
<Tooltip>
<div
class={twMerge(
'flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap px-1',
className
)}
title="Enterprise Edition only feature"
aria-label="Enterprise Edition only feature"
role="tooltip"
>
EE only <Building size={16} />
</div>
{#snippet text()}
{#if children}
{@render children()}
{:else}
Enterprise Edition only feature
{/if}
{/snippet}
</Tooltip>
+42 -1
View File
@@ -101,6 +101,8 @@
import { setMonacoTypescriptOptions } from './monacoLanguagesOptions'
import { copilotInfo } from '$lib/aiStore'
import { getDbSchemas } from './apps/components/display/dbtable/metadata'
import { rawAppLintStore, type MonacoLintError } from './raw_apps/lintStore'
import { MarkerSeverity } from 'monaco-editor'
import { resource, watch } from 'runed'
// import EditorTheme from './EditorTheme.svelte'
@@ -133,6 +135,8 @@
class?: string | undefined
moduleId?: string
enablePreprocessorSnippet?: boolean
/** When set, enables raw app lint collection mode and reports Monaco markers to the lint store under this key */
rawAppRunnableKey?: string | undefined
}
let {
@@ -160,7 +164,8 @@
key = undefined,
class: clazz = undefined,
moduleId = undefined,
enablePreprocessorSnippet = false
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined
}: Props = $props()
$effect.pre(() => {
@@ -353,6 +358,25 @@
editor.pushUndoStop()
}
}
// Update lint diagnostics after code change
updateRawAppLintDiagnostics()
}
/** Collect Monaco markers and update the raw app lint store */
function updateRawAppLintDiagnostics(): void {
if (!rawAppRunnableKey || !model) return
const markers = meditor.getModelMarkers({ resource: model.uri })
const lintErrors: MonacoLintError[] = markers
.filter((m) => m.severity === MarkerSeverity.Error || m.severity === MarkerSeverity.Warning)
.map((m) => ({
message: m.message,
severity: m.severity === MarkerSeverity.Error ? 'error' : 'warning',
startLineNumber: m.startLineNumber,
startColumn: m.startColumn,
endLineNumber: m.endLineNumber,
endColumn: m.endColumn
}))
rawAppLintStore.setDiagnostics(rawAppRunnableKey, lintErrors)
}
function updateCode() {
@@ -1331,6 +1355,20 @@
// updateEditorKeybindingsMode(editor, 'vim', undefined)
// Raw app lint collection: listen for marker changes and report to store
let markerChangeDisposable: IDisposable | undefined = undefined
if (rawAppRunnableKey && model) {
markerChangeDisposable = meditor.onDidChangeMarkers((uris) => {
if (!model || !rawAppRunnableKey) return
const modelUri = model.uri.toString()
if (uris.some((u) => u.toString() === modelUri)) {
updateRawAppLintDiagnostics()
}
})
// Initial lint diagnostics collection
updateRawAppLintDiagnostics()
}
let ataModel: number | undefined = undefined
editor?.onDidChangeModelContent((event) => {
@@ -1450,6 +1488,9 @@
closeWebsockets()
vimDisposable?.dispose()
closeAIInlineWidget()
markerChangeDisposable?.dispose()
// Note: We don't clear lint diagnostics on dispose - they persist across runnable switches
// Diagnostics are only updated when Monaco reports new markers for this runnable
console.log('disposing editor')
model?.dispose()
editor && editor.dispose()
+57 -1
View File
@@ -37,6 +37,7 @@
Plus,
RotateCw,
Save,
Settings,
Users
} from 'lucide-svelte'
import { capitalize, formatS3Object, toCamel } from '$lib/utils'
@@ -746,7 +747,62 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
(await WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? 'NO_W' })).map(
(path) => ({ path })
)}
/>
>
{#snippet submission()}
<div class="flex flex-row gap-x-1 mr-2">
<Button
startIcon={{ icon: Settings }}
target="_blank"
variant="accent"
href="{base}/workspace_settings?tab=windmill_lfs"
>
Go to settings
</Button>
</div>
{/snippet}
</ItemPicker>
{/if}
{#if showDataTablePicker}
<ItemPicker
bind:this={dataTablePicker}
pickCallback={async (_, name) => {
if (lang === 'duckdb') {
const connStr = name == 'main' ? 'datatable' : `datatable://${name}`
editor?.insertAtCursor(`ATTACH '${connStr}' AS dt;\n`)
} else if (lang === 'python3') {
if (!editor?.getCode().includes('import wmill')) {
editor?.insertAtBeginning('import wmill\n')
}
editor?.insertAtCursor(`db = wmill.datatable(${name == 'main' ? '' : `'${name}'`})\n`)
} else if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor?.getCode().includes('import * as wmill from')) {
editor?.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
}
editor?.insertAtCursor(`let sql = wmill.datatable(${name == 'main' ? '' : `'${name}'`})\n`)
}
}}
tooltip="Attach a datatable to your script."
documentationLink="https://www.windmill.dev/docs/core_concepts/data_tables"
itemName="data table"
loadItems={async () =>
(await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? 'NO_W' })).map(
(path) => ({ path })
)}
>
{#snippet submission()}
<div class="flex flex-row gap-x-1 mr-2">
<Button
startIcon={{ icon: Settings }}
target="_blank"
variant="accent"
href="{base}/workspace_settings?tab=windmill_data_tables"
>
Go to settings
</Button>
</div>
{/snippet}
</ItemPicker>
{/if}
{#if showDataTablePicker}
@@ -21,6 +21,7 @@
export let schema: any | undefined = undefined
export let stepDetail: FlowModule | string | undefined = undefined
export let jobScriptHash: string | undefined = undefined
let codeViewer: Drawer
</script>
@@ -219,7 +220,7 @@
></iframe>
</div>
{:else}
<FlowModuleScript path={stepDetail.value.path} />
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
{/if}
{:else if stepDetail.value.type == 'aiagent'}
<div class="text-2xs">
@@ -509,7 +509,12 @@
if (localModuleStates) {
innerModules?.forEach((mod, i) => {
if (mod.type === 'WaitingForEvents' && innerModules?.[i - 1]?.type === 'Success') {
setModuleState(mod.id ?? '', { type: mod.type, args: job?.args, tag: job?.tag })
setModuleState(mod.id ?? '', {
type: mod.type,
args: job?.args,
tag: job?.tag,
script_hash: job?.script_hash
})
} else if (
mod.type === 'WaitingForExecutor' &&
localModuleStates[mod.id ?? '']?.scheduled_for == undefined
@@ -527,7 +532,8 @@
job_id: job?.id,
parent_module: mod['parent_module'],
args: job?.args,
tag: job?.tag
tag: job?.tag,
script_hash: job?.script_hash
}
setModuleState(mod.id ?? '', newState)
@@ -851,7 +857,8 @@
args: job.args,
tag: job.tag,
started_at,
parent_module: mod['parent_module']
parent_module: mod['parent_module'],
script_hash: job.script_hash
},
force
)
@@ -888,7 +895,8 @@
iteration_total: mod.iterator?.itered?.length,
retries: mod?.failed_retries?.length,
skipped: mod.skipped,
agent_actions: mod.agent_actions
agent_actions: mod.agent_actions,
script_hash: job.script_hash
// retries: flowStateStore?.raw_flow
},
force
@@ -1979,7 +1987,8 @@
>{/if}
</div>
{:else if rightColumnSelect == 'node_definition'}
<FlowGraphViewerStep {stepDetail} />
{@const node = selectedNode ? localModuleStates[selectedNode] : undefined}
<FlowGraphViewerStep {stepDetail} jobScriptHash={node?.script_hash} />
{:else if rightColumnSelect == 'user_states'}
<div class="p-2">
<JobArgs argLabel="Key" args={job?.flow_status?.user_states ?? {}} />
@@ -5,7 +5,7 @@
import { Tab, Tabs, Button } from './common'
import { copyToClipboard } from '../utils'
import { ArrowDown, Clipboard } from 'lucide-svelte'
import { ArrowDown, Copy } from 'lucide-svelte'
import YAML from 'yaml'
import { yaml } from 'svelte-highlight/languages'
import HighlightTheme from './HighlightTheme.svelte'
@@ -80,7 +80,7 @@
color="light"
variant="border"
size="xs"
startIcon={{ icon: Clipboard }}
startIcon={{ icon: Copy }}
btnClasses="absolute top-2 right-2 w-min z-20"
iconOnly
/>
@@ -1,12 +1,18 @@
<script lang="ts">
import { AgentWorkersService, type ListBlacklistedAgentTokensResponse } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { Copy, Trash2, RefreshCw } from 'lucide-svelte'
import { ExternalLink, RefreshCw, Trash } from 'lucide-svelte'
import { Alert, Button, Tab, Tabs } from './common'
import Section from './Section.svelte'
import TagsToListenTo from './TagsToListenTo.svelte'
import { enterpriseLicense, superadmin } from '$lib/stores'
import CollapseLink from './CollapseLink.svelte'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
import CopyableCodeBlock from './details/CopyableCodeBlock.svelte'
import { shell, json } from 'svelte-highlight/languages'
import TokenDisplay from './settings/TokenDisplay.svelte'
import Description from './Description.svelte'
import { defaultTags, nativeTags } from './worker_group'
type Props = {
customTags: string[] | undefined
@@ -16,11 +22,14 @@
let workerGroup: string = $state('agent')
let token: string = $state('')
let blacklistToken: string = $state('')
let blacklistTokenError: string = $state('')
let selectedTab: 'create' | 'blacklist' = $state('create')
let blacklistedTokens: ListBlacklistedAgentTokensResponse | undefined = $state(undefined)
let isLoadingBlacklist: boolean = $state(false)
let isGeneratingToken: boolean = $state(false)
async function refreshToken(workerGroup: string, selectedTags: string[]) {
async function generateToken() {
isGeneratingToken = true
try {
const newToken = await AgentWorkersService.createAgentToken({
requestBody: {
@@ -31,8 +40,11 @@
})
token = newToken
sendUserToast('JWT token generated successfully')
} catch (error) {
sendUserToast('Error creating agent token: ' + error.toString(), true)
} finally {
isGeneratingToken = false
}
}
@@ -50,12 +62,27 @@
}
}
function validateBlacklistToken(token: string) {
if (!blacklistToken.trim()) {
blacklistTokenError = 'Token cannot be empty'
} else if (token && !token.startsWith('jwt_agent_')) {
blacklistTokenError = 'Token must start with jwt_agent_'
} else {
blacklistTokenError = ''
}
}
async function addToBlacklist() {
if (!blacklistToken.trim()) {
sendUserToast('Please enter a token to blacklist', true)
return
}
if (blacklistTokenError) {
sendUserToast('Invalid token format', true)
return
}
try {
await AgentWorkersService.blacklistAgentToken({
requestBody: {
@@ -65,6 +92,7 @@
sendUserToast('Token successfully added to blacklist')
blacklistToken = ''
blacklistTokenError = ''
// Refresh the blacklist after adding a new token
await loadBlacklistedTokens()
} catch (error) {
@@ -95,12 +123,6 @@
}
}
$effect(() => {
if (selectedTags.length > 0 && $superadmin) {
refreshToken(workerGroup, selectedTags)
}
})
$effect(() => {
if (selectedTab === 'blacklist' && $enterpriseLicense && $superadmin) {
loadBlacklistedTokens()
@@ -112,197 +134,242 @@
<Tab value="create" label="Create" />
<Tab value="blacklist" label="Blacklist" />
{#snippet content()}
<div class="flex flex-col gap-y-4 pt-2">
<div class="flex flex-col gap-y-6 pt-2">
{#if selectedTab === 'create'}
<Alert type="info" title="HTTP agent workers "
>Use HTTP agent workers only when the workers need to be deployed remotely OR with only
HTTP connectivity OR in untrusted environments. HTTP agent workers have more latency and
less capabilities than normal workers.</Alert
<Description
><a href="https://www.windmill.dev/docs/core_concepts/agent_workers" target="_blank"
>Agent workers <ExternalLink size={12} class="inline-block" /></a
> can be used to run jobs with remote workers with unreliable connectivity, workers behind
firewalls (HTTP-only), untrusted environments (no database access), or large deployments (thousands
of workers). They have more latency than normal workers. Follow the steps below to create an
agent worker.</Description
>
<div class="flex flex-col gap-y-4 mt-4">
<Section
<Section
label="1. Generate an agent worker token"
class="flex flex-col gap-y-6"
description="Generate a JWT token to authenticate the agent worker."
>
<Label
label="Worker group"
tooltip="This is only used to give a name prefix to the agent worker and to group workers in the workers page, no worker group config is passed to an agent worker."
>
<input class="max-w-md" type="text" bind:value={workerGroup} />
</Section>
<Section label="Tags to listen to" eeOnly>
</Label>
<Label
label="Tags to listen to"
eeOnly
tooltip="Tags determine which jobs this worker can execute. They are encoded in the JWT token and cannot be changed by the worker. You can use dynamic tags like 'tag-$args[argName]' or 'tag-$workspace' to target different workers based on job arguments or workspace."
>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
<div class="text-xs text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes,
you can only use the tag `agent_test` tag and it is limited to 100 jobs.
you can only use the `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<TagsToListenTo
disabled={!$enterpriseLicense}
bind:worker_tags={selectedTags}
{customTags}
/>
</Section>
<div class="flex flex-row gap-2 w-full">
<TagsToListenTo
class="grow min-w-0"
disabled={!$enterpriseLicense}
bind:worker_tags={selectedTags}
{customTags}
/>
<Button
variant="default"
unifiedSize="md"
onclick={() => {
selectedTags = [...defaultTags, ...nativeTags, ...(customTags ?? [])]
}}>Add all tags</Button
>
</div>
</Label>
<Section label="Generated JWT token">
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes,
you can only use the tag `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<div class="relative max-w-md group">
<input
onclick={(e) => {
e.preventDefault()
e.stopPropagation()
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
placeholder="Select tags to generate a JWT token"
type="text"
disabled
value={token}
class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncatere"
{#if !token}
<div class="mb-4">
<Button
variant="accent"
unifiedSize="md"
disabled={selectedTags.length === 0 || !$superadmin || isGeneratingToken}
onclick={generateToken}
loading={isGeneratingToken}
>
{isGeneratingToken ? 'Generating...' : 'Generate token'}
</Button>
{#if selectedTags.length === 0}
<div class="text-xs text-secondary mt-2">
Please select at least one tag to generate a token.
</div>
{:else if !$superadmin}
<div class="text-xs text-secondary mt-2">
Only superadmins can generate JWT tokens.
</div>
{/if}
</div>
{:else}
<TokenDisplay
{token}
title="JWT Token Generated Successfully"
onClose={() => {
token = ''
}}
/>
{/if}
</Section>
<Section label="2. Create an agent worker" class="flex flex-col gap-y-2">
<p class="text-xs text-primary">
Set these environment variables for your agent worker.
</p>
<CopyableCodeBlock
code={`MODE=agent
AGENT_TOKEN=<token>
BASE_INTERNAL_URL=<base url>
`}
language={shell}
/>
<p class="text-2xs text-secondary">
BASE_INTERNAL_URL: Base URL without trailing slash (e.g.,
<code>http://windmill.example.com</code>). Can be same as BASE_URL or private network
URL. <code>INIT_SCRIPT</code> can be passed as env variable if needed.
</p>
<Alert type="warning" size="sm" title="Agent Worker Limitations">
Ensure at least one normal worker is running and listening to the tags
<code>flow</code> and <code>dependency</code>
(or <code>flow-&lt;workspace&gt;</code> and
<code>dependency-&lt;workspace&gt;</code>
if using workspace-specific default tags), because agent workers
<strong>cannot run dependency jobs</strong>
nor execute the
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
</Alert>
<div class="mt-2"></div>
<Section small collapsable label="Automate JWT token generation">
<div class="text-xs text-primary">
<p class="mb-2">
Generate tokens programmatically using this endpoint with superadmin bearer token:
</p>
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
<p class="mb-2">Request body:</p>
<CopyableCodeBlock
code={`{
"worker_group": "agent",
"tags": ["tag1", "tag2"],
"exp": 1717334400
}`}
language={json}
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 group-hover:text-blue-600 hover:scale-105 transition"
aria-label="Copy token to clipboard"
onclick={(e) => {
e.preventDefault()
e.stopPropagation()
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
>
<Copy size={18} />
</button>
</div>
<div class="flex flex-col gap-2 text-sm mt-3 leading-relaxed">
Set the following environment variables:
<ul class="list-disc list-inside mt-1">
<li><code>MODE=agent</code></li>
<li><code>AGENT_TOKEN=&lt;token&gt;</code></li>
<li><code>BASE_INTERNAL_URL=&lt;base url&gt;</code></li>
</ul>
<p class="text-sm leading-relaxed">
to a worker to have it act as an HTTP agent worker.
<code>INIT_SCRIPT</code>, if needed, must be passed as an env variable.
<p class="mt-2">
<code>exp</code> is Unix timestamp. Response contains the JWT token.
</p>
<Alert type="warning" size="sm" title="Agent Worker Limitations">
Ensure at least one normal worker is running and listening to the tags
<code>flow</code> and <code>dependency</code>
(or <code>flow-&lt;workspace&gt;</code> and
<code>dependency-&lt;workspace&gt;</code>
if using workspace-specific default tags), because agent workers
<strong>cannot run dependency jobs</strong>
nor execute the
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
</Alert>
<CollapseLink text="Automate JWT token generation" small>
<div class="text-xs mt-2">
Use the following API endpoint with a superadmin bearer token:
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
<pre class=" p-2 rounded-lg text-xs overflow-auto">
<code
>{`
"worker_group": "agent",
"tags": ["tag1", "tag2"],
"exp": 1717334400
`}</code
>
</pre>
The JSON response will contain the generated JWT token.
</div>
</CollapseLink>
</div>
</Section>
</div>
</Section>
{:else if selectedTab === 'blacklist'}
<div class="flex flex-col gap-y-4 mt-4">
<div class="flex flex-col gap-y-4">
<Section label="Agent Token Blacklist" eeOnly>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
<div class="text-xs text-secondary mb-2 max-w-md">
Token blacklist management is only available in the enterprise edition.
</div>
{:else}
<div class="text-sm text-secondary mb-4 max-w-md">
Add tokens to the blacklist to prevent them from being used by agent workers.
Blacklisted tokens may take up to 5 minutes to be effective because of caching.
<div class="text-xs text-secondary mb-4 max-w-md">
Revoke tokens to prevent agent workers from authenticating. Blacklisted tokens may
take up to 5 minutes to be effective because of caching.
</div>
<div class="flex flex-col gap-3 w-full mb-6">
<div>
<label class="block text-sm font-medium mb-1" for="blacklistTokenInput"
>Token</label
>
<input
id="blacklistTokenInput"
class="w-full"
type="text"
bind:value={blacklistToken}
placeholder="jwt_agent_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ3b3JrZXJfZ3JvdXAiOiJhZ2VudCIsInN1ZmZpeCI6bnVsbCwidGFncyI6WyJiYXNoIl0sImV4cCI6MTg0NDk1NDYxMX0.JQWb-_ERGaomukbl_cEPPmmCAEepTR79d9oIrKREscE"
/>
</div>
<div class="flex">
<Button color="red" on:click={addToBlacklist} disabled={!$superadmin}
>Blacklist</Button
>
</div>
{#if !$superadmin}
<div class="text-xs text-amber-600">
Only superadmins can manage the token blacklist.
<Label
label="Token"
for="blacklistTokenInput"
tooltip="Blacklisted tokens cannot be used by agent workers to authenticate. Useful for revoking compromised tokens or decommissioning workers."
>
<div class="flex gap-2">
<TextInput
size="md"
inputProps={{
id: 'blacklistTokenInput',
placeholder:
'jwt_agent_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ3b3JrZXJfZ3JvdXAiOiJhZ2VudCIsInN1ZmZpeCI6bnVsbCwidGFncyI6WyJiYXNoIl0sImV4cCI6MTg0NDk1NDYxMX0.JQWb-_ERGaomukbl_cEPPmmCAEepTR79d9oIrKREscE',
type: 'text',
disabled: !$superadmin,
oninput: (e) =>
validateBlacklistToken((e.target as HTMLInputElement)?.value ?? '')
}}
bind:value={blacklistToken}
error={blacklistTokenError}
/>
<Button
variant="accent"
unifiedSize="md"
on:click={addToBlacklist}
disabled={!$superadmin || blacklistTokenError !== ''}>Blacklist</Button
>
</div>
{/if}
{#if blacklistTokenError !== ''}
<div class="text-xs text-red-600">
{blacklistTokenError}
</div>
{/if}
{#if !$superadmin}
<div class="text-xs text-amber-600">
Only superadmins can manage the token blacklist.
</div>
{/if}
</Label>
</div>
<!-- Blacklisted Tokens List -->
<div class="border-t pt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Blacklisted Tokens</h3>
<button
class="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 rounded-lg transition"
onclick={loadBlacklistedTokens}
<div class="pt-6">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs text-primary">Blacklisted tokens</h3>
<Button
variant="subtle"
unifiedSize="sm"
on:click={loadBlacklistedTokens}
disabled={isLoadingBlacklist}
title="Refresh blacklist"
>
<RefreshCw size={16} class={isLoadingBlacklist ? 'animate-spin' : ''} />
</button>
startIcon={{ icon: RefreshCw }}
iconProps={{ class: isLoadingBlacklist ? 'animate-spin' : '' }}
/>
</div>
{#if isLoadingBlacklist}
<div class="text-center py-4 text-gray-500"> Loading blacklisted tokens... </div>
<div class="text-center py-4 text-xs text-secondary">
Loading blacklisted tokens...
</div>
{:else if blacklistedTokens?.length === 0}
<div class="text-center py-4 text-gray-500">
<div class="text-center py-4 text-xs text-secondary">
No tokens are currently blacklisted.
</div>
{:else}
<div class="space-y-2">
{#each blacklistedTokens ?? [] as blacklistedToken}
{#each blacklistedTokens ?? [] as blacklistedToken (blacklistedToken.token)}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border"
class="flex items-center justify-between p-3 surface-tertiary rounded-lg border border-light"
>
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-gray-700 pr-4 break-all">
<div class="font-mono text-2xs text-emphasis pr-4 break-all">
{blacklistedToken.token}
</div>
{#if blacklistedToken.expires_at}
<div class="text-xs text-gray-500 mt-1">
<div class="text-2xs text-secondary mt-1">
Expires: {new Date(blacklistedToken.expires_at).toLocaleString()}
</div>
{/if}
</div>
{#if $superadmin}
<button
class="ml-3 p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition"
onclick={() => removeFromBlacklist(blacklistedToken.token)}
<Button
variant="subtle"
destructive
unifiedSize="sm"
on:click={() => removeFromBlacklist(blacklistedToken.token)}
title="Remove from blacklist"
>
<Trash2 size={16} />
</button>
startIcon={{ icon: Trash }}
/>
{/if}
</div>
{/each}
@@ -1,16 +1,7 @@
<script lang="ts">
import { isCloudHosted } from '$lib/cloud'
import { enterpriseLicense, isCriticalAlertsUIOpen } from '$lib/stores'
import {
AlertCircle,
AlertTriangle,
BadgeCheck,
BadgeX,
Info,
Plus,
Slack,
X
} from 'lucide-svelte'
import { AlertCircle, BadgeCheck, BadgeX, Info, Plus, Slack, X } from 'lucide-svelte'
import type { Setting } from './instanceSettings'
import Tooltip from './Tooltip.svelte'
import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte'
@@ -38,6 +29,7 @@
import LoadingIcon from './apps/svelte-select/lib/LoadingIcon.svelte'
import TeamSelector from './TeamSelector.svelte'
import ChannelSelector from './ChannelSelector.svelte'
import EEOnly from './EEOnly.svelte'
interface Props {
setting: Setting
@@ -195,16 +187,14 @@
}
}
}
</script>
<!-- {JSON.stringify($values, null, 2)} -->
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key])}
{#if setting.ee_only != undefined && !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap">
<AlertTriangle size={16} />
EE only {#if setting.ee_only != ''}<Tooltip>{setting.ee_only}</Tooltip>{/if}
</div>
<EEOnly>
{#if setting.ee_only != ''}{setting.ee_only}{/if}
</EEOnly>
{/if}
{#if setting.fieldType == 'select'}
<div>
@@ -571,13 +561,17 @@
{@const currentTeam = $values['critical_error_channels'][i]?.teams_channel
? {
team_id: $values['critical_error_channels'][i]?.teams_channel?.team_id,
team_name: $values['critical_error_channels'][i]?.teams_channel?.team_name
team_name:
$values['critical_error_channels'][i]?.teams_channel?.team_name
}
: undefined}
{@const currentChannel = $values['critical_error_channels'][i]?.teams_channel?.channel_id
{@const currentChannel = $values['critical_error_channels'][i]?.teams_channel
?.channel_id
? {
channel_id: $values['critical_error_channels'][i]?.teams_channel?.channel_id,
channel_name: $values['critical_error_channels'][i]?.teams_channel?.channel_name
channel_id:
$values['critical_error_channels'][i]?.teams_channel?.channel_id,
channel_name:
$values['critical_error_channels'][i]?.teams_channel?.channel_name
}
: undefined}
<div class="flex flex-row gap-2 w-full">
+9
View File
@@ -2,6 +2,8 @@
import { twMerge } from 'tailwind-merge'
import Required from './Required.svelte'
import Tooltip from './Tooltip.svelte'
import { enterpriseLicense } from '$lib/stores'
import EEOnly from './EEOnly.svelte'
interface Props {
label?: string | undefined
@@ -12,6 +14,7 @@
class?: string | undefined
for?: string | undefined
tooltip?: string | undefined
eeOnly?: boolean
header?: import('svelte').Snippet
error?: import('svelte').Snippet
action?: import('svelte').Snippet
@@ -27,6 +30,7 @@
class: clazz = undefined,
for: forAttr = undefined,
tooltip = undefined,
eeOnly = false,
header,
error,
action,
@@ -49,6 +53,11 @@
<Tooltip>{tooltip}</Tooltip>
{/if}
</span>
{#if eeOnly}
{#if !$enterpriseLicense}
<EEOnly />
{/if}
{/if}
{@render header?.()}
</div>
{/if}
@@ -0,0 +1,71 @@
<script lang="ts">
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import AssignableTagsInner from './AssignableTagsInner.svelte'
import DefaultTagsInner from './DefaultTagsInner.svelte'
import { ExternalLink } from 'lucide-svelte'
import { Section } from './common'
interface Props {
defaultTagPerWorkspace?: boolean | undefined
defaultTagWorkspaces?: string[]
onRefresh?: () => void
}
let {
defaultTagPerWorkspace = $bindable(undefined),
defaultTagWorkspaces = $bindable([]),
onRefresh
}: Props = $props()
let drawer: Drawer | undefined = $state(undefined)
export function openDrawer() {
drawer?.openDrawer?.()
}
export function closeDrawer() {
drawer?.closeDrawer?.()
}
export function toggleDrawer() {
drawer?.toggleDrawer?.()
}
</script>
<Drawer bind:this={drawer} size="800px">
<DrawerContent title="Manage tags" on:close={() => drawer?.closeDrawer?.()}>
<div class="flex flex-col h-full gap-6">
<!-- Overall Description -->
<div class="text-xs font-normal text-secondary">
Tags determine which worker group will execute a given job. Workers process only those jobs
whose tags match those defined in their <a
href="https://www.windmill.dev/docs/core_concepts/worker_groups"
target="_blank">worker group <ExternalLink size={12} class="inline-block" /></a
>
configuration.
</div>
<!-- Content Sections -->
<div class="flex flex-col gap-8 flex-1">
<!-- Custom Tags Section -->
<Section label="Custom tags">
<AssignableTagsInner
variant="drawer"
on:refresh={() => {
if (onRefresh) {
onRefresh()
}
}}
/>
</Section>
<!-- Default Tags Section -->
<DefaultTagsInner bind:defaultTagPerWorkspace bind:defaultTagWorkspaces />
<!-- Extra padding -->
<div class="pb-10"></div>
</div>
</div>
</DrawerContent>
</Drawer>
@@ -0,0 +1,46 @@
<script lang="ts">
import MeltTooltip from '$lib/components/meltComponents/Tooltip.svelte'
interface Props {
rate_15s?: number
rate_5m?: number
rate_30m?: number
rate_ever?: number
}
let { rate_15s, rate_5m, rate_30m, rate_ever }: Props = $props()
function displayOccupancyRate(occupancy_rate: number | undefined) {
if (occupancy_rate == undefined) {
return '--'
}
return Math.ceil(occupancy_rate * 100) + '%'
}
const rates = $derived([
{ value: rate_15s, label: '15s' },
{ value: rate_5m, label: '5m' },
{ value: rate_30m, label: '30m' },
{ value: rate_ever, label: 'ever' }
])
</script>
<div class="flex gap-1 items-end py-1">
{#each rates as rate}
<MeltTooltip>
<div class="relative w-4 h-8 bg-surface-secondary rounded-sm border shadow-sm">
{#if rate.value !== undefined && rate.value > 0}
{@const heightPercent = Math.min(rate.value * 100, 100)}
{@const minHeight = heightPercent > 0 && heightPercent < 3 ? 1 : heightPercent}
<div
class="absolute bottom-0 left-0 right-0 bg-surface-accent-primary rounded-sm transition-all duration-200"
style="height: {minHeight < 3 ? `${minHeight}px` : `${heightPercent}%`}"
></div>
{/if}
</div>
{#snippet text()}
{rate.label}: {rate.value ? displayOccupancyRate(rate.value) : '--'}
{/snippet}
</MeltTooltip>
{/each}
</div>
@@ -0,0 +1,572 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Button } from '$lib/components/common'
import Section from '$lib/components/Section.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Popover } from '$lib/components/meltComponents'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { Plus, Edit3, Save, X, Trash, ExternalLink } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { twMerge } from 'tailwind-merge'
import { ConfigService, type Alert } from '$lib/gen'
import Tooltip from './Tooltip.svelte'
import Badge from './common/badge/Badge.svelte'
import { enterpriseLicense } from '$lib/stores'
let queueAlertConfig = $state<Alert[]>([])
let availableTags = $state<string[]>([])
let configName = 'alert__job_queue_waiting'
let editingRowIndex = $state<number>(-1)
let editForm = $state<{
tags_to_monitor: string[]
jobs_num_threshold: string
alert_cooldown_seconds: string
alert_time_threshold_seconds: string
}>({
tags_to_monitor: [],
jobs_num_threshold: '',
alert_cooldown_seconds: '',
alert_time_threshold_seconds: ''
})
let newAlertForm = $state({
tags_to_monitor: [] as string[],
jobs_num_threshold: '3',
alert_cooldown_seconds: '600',
alert_time_threshold_seconds: '30'
})
let addAlertOpen = $state(false)
let formErrors = $state<Record<string, string>>({})
let expandedTagRows = $state<number[]>([])
const MAX_NUMBER_OF_TAGS_DISPLAYED = 10
onMount(async () => {
await fetchConfig()
availableTags = await fetchWorkerTags()
})
async function fetchConfig() {
try {
const response = await ConfigService.getConfig({ name: configName })
queueAlertConfig = response?.alerts || []
expandedTagRows = []
} catch (error) {
console.error('Failed to fetch config:', error)
}
}
async function fetchWorkerTags(): Promise<string[]> {
try {
const response = await ConfigService.listConfigs()
const workerTagsSet = new Set<string>()
response.forEach((config) => {
if (config.name.startsWith('worker__') && Array.isArray(config.config?.worker_tags)) {
config?.config?.worker_tags.forEach((tag) => workerTagsSet.add(tag))
}
})
return Array.from(workerTagsSet)
} catch (error) {
console.error('Failed to fetch worker tags:', error)
return []
}
}
function startEditing(index: number) {
editingRowIndex = index
const config = queueAlertConfig[index]
editForm = {
tags_to_monitor: [...config.tags_to_monitor],
jobs_num_threshold: config.jobs_num_threshold.toString(),
alert_cooldown_seconds: config.alert_cooldown_seconds.toString(),
alert_time_threshold_seconds: config.alert_time_threshold_seconds.toString()
}
// Reset expanded state when entering edit mode
expandedTagRows = expandedTagRows.filter((i) => i !== index)
}
function cancelEdit() {
editingRowIndex = -1
formErrors = {}
}
async function saveEdit() {
if (!validateForm(editForm)) return
try {
queueAlertConfig[editingRowIndex] = {
name: 'Job Queue Alert',
tags_to_monitor: editForm.tags_to_monitor,
jobs_num_threshold: parseInt(editForm.jobs_num_threshold),
alert_cooldown_seconds: parseInt(editForm.alert_cooldown_seconds),
alert_time_threshold_seconds: parseInt(editForm.alert_time_threshold_seconds)
}
await saveQueueAlertConfig()
editingRowIndex = -1
formErrors = {}
sendUserToast('Alert configuration updated successfully')
} catch (error) {
sendUserToast('Failed to update alert configuration', true)
}
}
async function deleteAlert(index: number) {
try {
queueAlertConfig.splice(index, 1)
await saveQueueAlertConfig()
// Clean up expanded state for deleted row and shift indices down
expandedTagRows = expandedTagRows
.filter((i) => i !== index)
.map((i) => (i > index ? i - 1 : i))
sendUserToast('Alert deleted successfully')
} catch (error) {
sendUserToast('Failed to delete alert', true)
}
}
function validateForm(form: typeof editForm | typeof newAlertForm): boolean {
formErrors = {}
let isValid = true
if (form.tags_to_monitor.length === 0) {
formErrors.tags_to_monitor = 'At least one tag is required'
isValid = false
}
const jobsThreshold = parseInt(form.jobs_num_threshold)
if (isNaN(jobsThreshold) || jobsThreshold < 1) {
formErrors.jobs_num_threshold = 'Must be a positive number'
isValid = false
}
const cooldown = parseInt(form.alert_cooldown_seconds)
if (isNaN(cooldown) || cooldown < 1) {
formErrors.alert_cooldown_seconds = 'Must be a positive number'
isValid = false
}
const timeThreshold = parseInt(form.alert_time_threshold_seconds)
if (isNaN(timeThreshold) || timeThreshold < 1) {
formErrors.alert_time_threshold_seconds = 'Must be a positive number'
isValid = false
}
return isValid
}
async function addNewAlert() {
if (!validateForm(newAlertForm)) return
try {
queueAlertConfig.push({
name: 'Job Queue Alert',
tags_to_monitor: newAlertForm.tags_to_monitor,
jobs_num_threshold: parseInt(newAlertForm.jobs_num_threshold),
alert_cooldown_seconds: parseInt(newAlertForm.alert_cooldown_seconds),
alert_time_threshold_seconds: parseInt(newAlertForm.alert_time_threshold_seconds)
})
await saveQueueAlertConfig()
// Reset form
newAlertForm = {
tags_to_monitor: [],
jobs_num_threshold: '3',
alert_cooldown_seconds: '600',
alert_time_threshold_seconds: '30'
}
addAlertOpen = false
formErrors = {}
sendUserToast('Alert added successfully')
} catch (error) {
sendUserToast('Failed to add alert', true)
}
}
async function saveQueueAlertConfig() {
await ConfigService.updateConfig({
name: configName,
requestBody: { alerts: queueAlertConfig }
})
}
function safeSelectItems(items: string[]) {
return items.map((item) => ({ label: item, value: item }))
}
</script>
<Section
label="Queue alerts"
description={$enterpriseLicense
? 'Configure alerts for queue monitoring based on worker tags and thresholds'
: ''}
eeOnly
>
{#snippet action()}
{#if $enterpriseLicense}
<Popover
bind:isOpen={addAlertOpen}
closeButton
placement="bottom-end"
contentClasses="p-4 w-96 max-w-96"
>
{#snippet trigger()}
<Button variant="default" unifiedSize="md" startIcon={{ icon: Plus }}
>Add new alert</Button
>
{/snippet}
{#snippet content()}
<form class="flex flex-col gap-y-6">
<h3 class="text-sm font-semibold text-emphasis">Add queue alert</h3>
<div class="flex flex-col gap-y-1">
<label for="new-tags" class="text-xs font-semibold text-emphasis">
Worker tags to monitor
</label>
<span class="text-xs font-normal text-secondary">
Tags that identify which workers to monitor for this alert
</span>
<div class="flex gap-2 items-start">
<MultiSelect
items={safeSelectItems(availableTags)}
bind:value={newAlertForm.tags_to_monitor}
createText="Press Enter to add custom tag"
placeholder="Select or create tags..."
error={!!formErrors.tags_to_monitor}
class="flex-1"
disablePortal
/>
{#if newAlertForm.tags_to_monitor.length === 0}
<Button
variant="default"
unifiedSize="md"
onclick={() => {
newAlertForm.tags_to_monitor = [...availableTags]
}}
>
Add all tags
</Button>
{/if}
</div>
{#if formErrors.tags_to_monitor}
<span class="text-2xs font-normal text-red-500">{formErrors.tags_to_monitor}</span>
{/if}
</div>
<div class="flex flex-col gap-y-1">
<label for="new-jobs-threshold" class="text-xs font-semibold text-emphasis">
Jobs count threshold
</label>
<span class="text-xs font-normal text-secondary">
Trigger alert when queue exceeds this many jobs
</span>
<TextInput
inputProps={{
id: 'new-jobs-threshold',
type: 'number',
min: '1',
placeholder: '3'
}}
bind:value={newAlertForm.jobs_num_threshold}
size="sm"
class="w-full"
error={!!formErrors.jobs_num_threshold}
/>
{#if formErrors.jobs_num_threshold}
<span class="text-2xs font-normal text-red-500"
>{formErrors.jobs_num_threshold}</span
>
{/if}
</div>
<div class="flex flex-col gap-y-1">
<label for="new-cooldown" class="text-xs font-semibold text-emphasis">
Alert cooldown (seconds)
</label>
<span class="text-xs font-normal text-secondary">
Wait time between alerts for the same condition
</span>
<TextInput
inputProps={{
id: 'new-cooldown',
type: 'number',
min: '1',
placeholder: '600'
}}
bind:value={newAlertForm.alert_cooldown_seconds}
size="sm"
class="w-full"
error={!!formErrors.alert_cooldown_seconds}
/>
{#if formErrors.alert_cooldown_seconds}
<span class="text-2xs font-normal text-red-500"
>{formErrors.alert_cooldown_seconds}</span
>
{/if}
</div>
<div class="flex flex-col gap-y-1">
<label for="new-time-threshold" class="text-xs font-semibold text-emphasis">
Time threshold (seconds)
</label>
<span class="text-xs font-normal text-secondary">
How long the condition must persist before alerting
</span>
<TextInput
inputProps={{
id: 'new-time-threshold',
type: 'number',
min: '1',
placeholder: '30'
}}
bind:value={newAlertForm.alert_time_threshold_seconds}
size="sm"
class="w-full"
error={!!formErrors.alert_time_threshold_seconds}
/>
{#if formErrors.alert_time_threshold_seconds}
<span class="text-2xs font-normal text-red-500"
>{formErrors.alert_time_threshold_seconds}</span
>
{/if}
</div>
<div class="flex gap-x-2 pt-2 justify-end">
<Button
type="button"
variant="default"
unifiedSize="md"
onclick={() => {
addAlertOpen = false
formErrors = {}
}}
>
Cancel
</Button>
<Button
onClick={addNewAlert}
type="submit"
variant="accent"
unifiedSize="md"
startIcon={{ icon: Plus }}>Add alert</Button
>
</div>
</form>
{/snippet}
</Popover>
{/if}
{/snippet}
{#if !$enterpriseLicense}
<div class="text-xs text-primary">
Queue Metric Alerts is an enterprise feature allowing you to monitor queues for waiting jobs.
Please upgrade to access this functionality. <a
href="https://www.windmill.dev/pricing"
target="_blank"
>Learn more about our plans <ExternalLink size={12} class="inline-block" /></a
>
</div>
{:else if queueAlertConfig.length === 0}
<div class="text-center py-8">
<p class="text-sm text-secondary">No queue alerts configured</p>
<p class="text-xs text-hint mt-1">Add your first alert to monitor queue conditions</p>
</div>
{:else}
<div class="overflow-x-auto border rounded-md">
<table class="w-full">
<thead>
<tr class="border-b bg-surface-secondary">
<th class="text-left py-3 px-4 text-xs font-normal text-normal min-w-48">
<span class="inline-flex items-center gap-1">
Worker Tags
<Tooltip>Tags that identify which workers to monitor for this alert</Tooltip>
</span>
</th>
<th class="text-left py-3 px-4 text-xs font-normal text-normal">
<span class="inline-flex items-center gap-1">
Jobs Threshold
<Tooltip>Trigger alert when queue exceeds this many jobs</Tooltip>
</span>
</th>
<th class="text-left py-3 px-4 text-xs font-normal text-normal">
<span class="inline-flex items-center gap-1">
Cooldown (s)
<Tooltip>Wait time between alerts for the same condition</Tooltip>
</span>
</th>
<th class="text-left py-3 px-4 text-xs font-normal text-normal">
<span class="inline-flex items-center gap-1">
Time Threshold (s)
<Tooltip>How long the condition must persist before alerting</Tooltip>
</span>
</th>
<th class="text-right py-3 px-4 text-xs font-normal text-normal">Actions</th>
</tr>
</thead>
<tbody>
{#each queueAlertConfig as config, index}
<tr
class={twMerge(
'text-xs text-primary',
index !== queueAlertConfig.length - 1 ? 'border-b' : '',
editingRowIndex === index ? 'bg-surface-selected' : ''
)}
>
<td class="p-2">
{#if editingRowIndex === index}
<div class="flex gap-2 items-start">
<MultiSelect
items={safeSelectItems(availableTags)}
bind:value={editForm.tags_to_monitor}
onCreateItem={(tag) => {
if (!editForm.tags_to_monitor.includes(tag)) {
editForm.tags_to_monitor = [...editForm.tags_to_monitor, tag]
}
}}
createText="Press Enter to add custom tag"
placeholder="Select or create tags..."
class="flex-1"
/>
{#if editForm.tags_to_monitor.length === 0}
<Button
variant="default"
unifiedSize="md"
onclick={() => {
editForm.tags_to_monitor = [...availableTags]
}}
>
Add all tags
</Button>
{/if}
</div>
{:else}
{@const isExpanded = expandedTagRows.includes(index)}
{@const tagsToShow =
isExpanded || config.tags_to_monitor.length <= MAX_NUMBER_OF_TAGS_DISPLAYED
? config.tags_to_monitor
: config.tags_to_monitor.slice(0, MAX_NUMBER_OF_TAGS_DISPLAYED)}
<div class="flex flex-wrap gap-1">
{#each tagsToShow as tag}
<Badge color="blue" small>
{tag}
</Badge>
{/each}
{#if config.tags_to_monitor.length > MAX_NUMBER_OF_TAGS_DISPLAYED && !isExpanded}
<Badge
clickable
color="blue"
small
onclick={() => {
expandedTagRows = [...expandedTagRows, index]
}}
>
+ {config.tags_to_monitor.length}
</Badge>
{/if}
</div>
{/if}
</td>
<td class="p-2">
{#if editingRowIndex === index}
<TextInput
inputProps={{
type: 'number',
min: '1'
}}
bind:value={editForm.jobs_num_threshold}
size="sm"
class="w-20"
/>
{:else}
<span>{config.jobs_num_threshold}</span>
{/if}
</td>
<td class="p-2">
{#if editingRowIndex === index}
<TextInput
inputProps={{
type: 'number',
min: '1'
}}
bind:value={editForm.alert_cooldown_seconds}
size="sm"
class="w-24"
/>
{:else}
<span>{config.alert_cooldown_seconds}</span>
{/if}
</td>
<td class="p-2">
{#if editingRowIndex === index}
<TextInput
inputProps={{
type: 'number',
min: '1'
}}
bind:value={editForm.alert_time_threshold_seconds}
size="sm"
class="w-24"
/>
{:else}
<span>{config.alert_time_threshold_seconds}</span>
{/if}
</td>
<td class="p-2">
{#if editingRowIndex === index}
<div class="flex items-center gap-2 justify-end">
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Save }}
onclick={saveEdit}
>
Save
</Button>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: X }}
onclick={cancelEdit}
>
Cancel
</Button>
</div>
{:else}
<div class="flex items-center gap-2 justify-end">
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Edit3 }}
onclick={() => startEditing(index)}
disabled={editingRowIndex !== -1}
>
Edit
</Button>
<Button
variant="subtle"
destructive
unifiedSize="sm"
onclick={() => deleteAlert(index)}
disabled={editingRowIndex !== -1}
startIcon={{ icon: Trash }}
>
Delete
</Button>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Section>
@@ -1,423 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Drawer, DrawerContent, Button } from './common'
import { Drawer, DrawerContent } from './common'
import QueueMetricsDrawerInner from './QueueMetricsDrawerInner.svelte'
import { ConfigService, type Alert } from '$lib/gen'
import Section from './Section.svelte'
import { sendUserToast } from '$lib/toast'
import { Pencil, Trash, Check, PlusCircle, SaveIcon } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { enterpriseLicense } from '$lib/stores'
function updateChangesMade() {
changesMade = JSON.stringify(alerts) !== JSON.stringify(originalAlerts)
}
function handleInput(event) {
const target = event.target
console.log(target)
if (target.tagName.toLowerCase() === 'input') {
updateChangesMade()
}
}
import QueueAlerts from './QueueAlerts.svelte'
let drawer: Drawer
export function openDrawer() {
drawer?.openDrawer()
}
let alerts: Alert[] = []
let configName = 'alert__job_queue_waiting'
let originalAlerts: Alert[] = []
let newTag = ''
let editingIndex = -1
let changesMade = false
let removedAlerts: Alert[] = []
let stagedNewAlert = false
let workerTags: string[] = []
let filteredTags: string[] = []
$: removedAlerts
onMount(async () => {
await fetchConfig()
workerTags = await fetchWorkerTags()
})
async function fetchConfig() {
try {
const response = await ConfigService.getConfig({ name: configName })
alerts = response?.alerts || []
originalAlerts = JSON.parse(JSON.stringify(alerts))
} catch (error) {
console.error('Failed to fetch config:', error)
}
}
async function fetchWorkerTags(): Promise<string[]> {
try {
const response = await ConfigService.listConfigs()
const workerTagsSet = new Set<string>()
response.forEach((config) => {
if (config.name.startsWith('worker__') && Array.isArray(config.config?.worker_tags)) {
config?.config?.worker_tags.forEach((tag) => workerTagsSet.add(tag))
}
})
return Array.from(workerTagsSet)
} catch (error) {
console.error('Failed to fetch worker tags:', error)
return []
}
}
function startEditing(index) {
if (editingIndex !== -1) {
const success = saveAlert(editingIndex)
if (!success) return
}
editingIndex = index
updateWorkerTags()
}
function saveAlert(index): boolean {
const newAlert = alerts[index]
if (newAlert.tags_to_monitor.length === 0) {
sendUserToast('Please add at least one tag before saving.', true)
return false
}
if (
newAlert.jobs_num_threshold <= 0 ||
newAlert.alert_cooldown_seconds <= 0 ||
newAlert.alert_time_threshold_seconds <= 0
) {
sendUserToast('All numeric values must be strictly positive.', true)
return false
}
const alertExists = originalAlerts.some(
(alert) =>
originalAlerts.indexOf(alert) !== index &&
JSON.stringify(alert.tags_to_monitor.sort()) ===
JSON.stringify(newAlert.tags_to_monitor.sort())
)
if (alertExists) {
sendUserToast('You can only define one alert per identical set of tags', true)
return false
}
editingIndex = -1
updateChangesMade()
stagedNewAlert = false
return true
}
function stageDeleteAlert(index) {
const alert = alerts[index]
removedAlerts = [...removedAlerts, alert]
changesMade =
removedAlerts.length > 0 || JSON.stringify(alerts) !== JSON.stringify(originalAlerts)
}
function filterTags(event: Event) {
const input = (event.target as HTMLInputElement).value
filteredTags = workerTags.filter((tag) => tag.toLowerCase().includes(input.toLowerCase()))
}
function addTag(alertIndex, tag) {
if (workerTags.includes(tag) && !alerts[alertIndex].tags_to_monitor.includes(tag)) {
alerts[alertIndex].tags_to_monitor = [...alerts[alertIndex].tags_to_monitor, tag]
}
newTag = ''
filteredTags = []
updateChangesMade()
}
function removeTag(alertIndex, tag) {
alerts[alertIndex].tags_to_monitor = alerts[alertIndex].tags_to_monitor.filter((t) => t !== tag)
updateChangesMade()
}
async function applyConfig() {
if (editingIndex !== -1) {
const success = saveAlert(editingIndex)
if (!success) return
}
try {
await ConfigService.updateConfig({ name: configName, requestBody: { alerts } })
sendUserToast('Configuration updated successfully')
alerts = alerts.filter((alert) => !removedAlerts.includes(alert))
originalAlerts = JSON.parse(JSON.stringify(alerts))
removedAlerts = []
changesMade = false
stagedNewAlert = false
editingIndex = -1
} catch (error) {
console.error('Failed to update config:', error)
}
}
async function cancelChanges() {
alerts = [...alerts, ...removedAlerts]
alerts = JSON.parse(JSON.stringify(originalAlerts))
removedAlerts = []
editingIndex = -1
changesMade = false
stagedNewAlert = false
}
function addNewAlert() {
// alert already being added
if (stagedNewAlert) {
return
}
const newAlert = {
name: 'Job Queue Alert',
tags_to_monitor: [],
jobs_num_threshold: 3,
alert_cooldown_seconds: 600,
alert_time_threshold_seconds: 30
}
alerts = [...alerts, newAlert]
editingIndex = alerts.length - 1
stagedNewAlert = true
updateChangesMade()
updateWorkerTags()
}
async function updateWorkerTags() {
workerTags = await fetchWorkerTags()
}
function addAllTags(alertIndex) {
alerts[alertIndex].tags_to_monitor = [
...new Set([...alerts[alertIndex].tags_to_monitor, ...workerTags])
]
alerts = [...alerts]
updateChangesMade()
}
</script>
<Drawer bind:this={drawer} size="800px">
<Drawer bind:this={drawer} size="1000px">
<DrawerContent
title="Queues"
on:close={drawer.closeDrawer}
documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups#queue-metrics"
>
<Section
label="Queue alert settings"
collapsable={true}
tooltip="A critical alert is triggered when the number of jobs in the queue exceeds the set threshold and they have been waiting for at least the specified time. After an alert, no new alerts will be triggered during the cooldown period."
eeOnly={true}
>
{#if $enterpriseLicense}
{#if changesMade}
<div class="text-red-600 text-xs whitespace-nowrap pb-2">Non applied changes</div>
{/if}
<div class="flex gap-2 pb-2">
<Button color="blue" size="xs" on:click={applyConfig} disabled={!changesMade}>
<SaveIcon size={16} /> Apply config
</Button>
<Button color="light" size="xs" on:click={cancelChanges} disabled={!changesMade}>
Cancel
</Button>
</div>
<QueueAlerts />
{#if alerts.length > 0}
<div>
<form on:submit|preventDefault>
<table class="w-full border-collapse mb-2 text-xs table-auto">
<thead class="bg-gray-200 dark:bg-slate-600 text-left text-xs">
<tr>
<th class="p-2 w-full">
Queue Tags to Monitor
<Tooltip markdownTooltip="Queue tags to monitor for this alert." />
</th>
<th class="p-2 min-w-[65px]">
Jobs
<Tooltip
markdownTooltip="Number of jobs threshold: An alert will be triggered if the number of jobs in the queue exceeds this threshold and they have been waiting for at least the specified time threshold."
/>
</th>
<th class="p-2 min-w-[115px]">
Cooldown (s)
<Tooltip
markdownTooltip="Cooldown period in seconds: This defines the time interval after an alert is triggered during which no additional alerts will be sent."
/>
</th>
<th class="p-2 min-w-[105px]">
Time (s)
<Tooltip
markdownTooltip="Time threshold in seconds: An alert will be triggered if the number of jobs in the queue exceeds the job threshold and they have remained in the queue for at least this duration."
/>
</th>
<th class="p-2 min-w-[100px]"> Actions </th>
</tr>
</thead>
<tbody on:input={handleInput}>
{#each alerts as alert, index}
<tr
class={removedAlerts.includes(alert)
? 'bg-red-100 dark:bg-red-900 pointer-events-none opacity-50'
: ''}
>
<td class="border p-2">
{#if editingIndex === index}
<div class="flex flex-wrap gap-1 mb-2">
{#each alert.tags_to_monitor as tag}
<span
class="inline-block bg-blue-100 dark:bg-blue-700 rounded px-2 py-1 text-xs"
>
{tag}
<button
on:click={() => removeTag(index, tag)}
aria-label="Remove tag"
class="ml-1 text-xs">x</button
>
</span>
{/each}
</div>
<div class="flex items-center">
<input
type="text"
bind:value={newTag}
placeholder={workerTags.length === alert.tags_to_monitor.length
? 'All tags already added'
: 'Add tag from dropdown'}
on:input={(e) => filterTags(e)}
disabled={workerTags.length === alert.tags_to_monitor.length}
class="p-1 flex-grow mr-1"
/>
<button on:click={() => addTag(index, newTag)} aria-label="Add tag">
<PlusCircle size={16} />
</button>
</div>
<!-- Add the new "Add All Tags" button here -->
<button
on:click={() => addAllTags(index)}
class="text-xs hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-1 mt-1"
disabled={workerTags.length === alert.tags_to_monitor.length}
>
Add All Tags
</button>
{#if filteredTags.length > 0}
<ul
class="autocomplete-list border max-h-36 overflow-y-auto absolute z-50"
>
{#each filteredTags as tag}
{#if !alert.tags_to_monitor.includes(tag)}
<li>
<button
type="button"
class="w-full text-left p-2 cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-700"
on:click={() => addTag(index, tag)}
>
{tag}
</button>
</li>
{/if}
{/each}
</ul>
{/if}
{:else}
<div class="flex flex-wrap gap-1">
{#each alert.tags_to_monitor as tag}
<span
class="inline-block bg-blue-100 dark:bg-blue-700 rounded px-2 py-1 text-xs"
>{tag}</span
>
{/each}
</div>
{/if}
</td>
<td class="border p-2">
{#if editingIndex === index}
<input
type="number"
bind:value={alert.jobs_num_threshold}
class="w-full p-1"
/>
{:else}
{alert.jobs_num_threshold}
{/if}
</td>
<td class="border p-2">
{#if editingIndex === index}
<input
type="number"
bind:value={alert.alert_cooldown_seconds}
class="w-full p-1"
/>
{:else}
{alert.alert_cooldown_seconds}
{/if}
</td>
<td class="border p-2">
{#if editingIndex === index}
<input
type="number"
bind:value={alert.alert_time_threshold_seconds}
class="w-full p-1"
/>
{:else}
{alert.alert_time_threshold_seconds}
{/if}
</td>
<td class="border p-2">
<div class="flex gap-3 justify-center items-center">
{#if editingIndex === index}
<button on:click={() => saveAlert(index)} aria-label="Save">
<Check size={16} />
</button>
{:else}
<button on:click={() => startEditing(index)} aria-label="Edit">
<Pencil size={16} />
</button>
<button on:click={() => stageDeleteAlert(index)} aria-label="Delete">
<Trash size={16} />
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</form>
</div>
{/if}
<div class="py-8"></div>
<!-- Button to Add New Alert at the Bottom of the Table -->
<div class="flex">
<Button color="blue" size="xs" on:click={addNewAlert}>
<PlusCircle size={16} />
Add new alert
</Button>
</div>
{:else}
<p class="text-sm">
Queue Metric Alerts are an enterprise feature allowing you to monitor queues for waiting
jobs. Please upgrade to access this functionality.
<a
href="https://www.windmill.dev/docs/misc/plans_details"
target="_blank"
class="text-blue-500 underline">Learn more about our plans.</a
>
</p>
{/if}
</Section>
<h1 class="pt-4">Queue Metrics</h1>
<div class="p-8">
<QueueMetricsDrawerInner />
</div>
<QueueMetricsDrawerInner />
<div class="py-8"></div>
</DrawerContent>
</Drawer>
@@ -20,6 +20,7 @@
import Skeleton from './common/skeleton/Skeleton.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import Alert from './common/alert/Alert.svelte'
import { Section } from './common'
let loading: boolean = true
@@ -186,87 +187,89 @@
<DarkModeObserver bind:darkMode />
{#if loading}
<Skeleton layout={[[20]]} />
{:else if noMetrics}
<p class="text-secondary">No jobs delayed by more than 3 seconds in the last 14 days</p>
{:else}
<div class="flex flex-col gap-4">
{#if countData}
<Line
data={countData}
options={{
animation: false,
plugins: {
title: {
display: true,
text: 'Number of delayed jobs per tag (> 3s)'
}
},
scales: {
x: {
type: 'time',
min: minDate.toISOString(),
max: new Date().toISOString()
},
y: {
<Section label="Queue metrics">
{#if loading}
<Skeleton layout={[[20]]} />
{:else if noMetrics}
<p class="text-secondary">No jobs delayed by more than 3 seconds in the last 14 days</p>
{:else}
<div class="flex flex-col gap-4">
{#if countData}
<Line
data={countData}
options={{
animation: false,
plugins: {
title: {
display: true,
text: 'count'
text: 'Number of delayed jobs per tag (> 3s)'
}
}
}
}}
/>
{/if}
{#if delayData}
<Line
data={delayData}
options={{
animation: false,
plugins: {
title: {
display: true,
text: 'Queue delay per tag (> 3s)'
},
tooltip: {
callbacks: {
label: function (context) {
// @ts-ignore
if (context.raw.y === 1) {
return context.dataset.label + ': 0'
} else {
// @ts-ignore
return context.dataset.label + ': ' + context.raw.y
}
scales: {
x: {
type: 'time',
min: minDate.toISOString(),
max: new Date().toISOString()
},
y: {
title: {
display: true,
text: 'count'
}
}
}
},
scales: {
x: {
type: 'time',
min: minDate.toISOString(),
max: new Date().toISOString()
},
y: {
type: 'logarithmic',
}}
/>
{/if}
{#if delayData}
<Line
data={delayData}
options={{
animation: false,
plugins: {
title: {
display: true,
text: 'delay (s)'
text: 'Queue delay per tag (> 3s)'
},
ticks: {
callback: (value, _) => (value === 1 ? '0' : value)
tooltip: {
callbacks: {
label: function (context) {
// @ts-ignore
if (context.raw.y === 1) {
return context.dataset.label + ': 0'
} else {
// @ts-ignore
return context.dataset.label + ': ' + context.raw.y
}
}
}
}
},
scales: {
x: {
type: 'time',
min: minDate.toISOString(),
max: new Date().toISOString()
},
y: {
type: 'logarithmic',
title: {
display: true,
text: 'delay (s)'
},
ticks: {
callback: (value, _) => (value === 1 ? '0' : value)
}
}
}
}
}}
/>
{/if}
<Alert title="Info">
Only tags for jobs that have been delayed by more than 3 seconds in the last 14 days are
included in the graph.
</Alert>
</div>
{/if}
}}
/>
{/if}
<Alert title="Info">
Only tags for jobs that have been delayed by more than 3 seconds in the last 14 days are
included in the graph.
</Alert>
</div>
{/if}
</Section>
@@ -12,7 +12,7 @@
PostgresTriggerService,
CaptureService,
type ScriptLang,
WorkerService,
WorkerService
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import {
@@ -189,7 +189,7 @@
: undefined
)
const simplifiedPoll = writable(false)
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
@@ -523,6 +523,7 @@
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
debounce_delay_s: script.debounce_delay_s,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
@@ -666,6 +667,7 @@
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
debounce_delay_s: script.debounce_delay_s,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
@@ -1046,7 +1048,7 @@
{#snippet content()}
<div class="min-h-0 grow overflow-y-auto">
<TabContent value="metadata">
<div class="flex flex-col gap-8 px-4 py-2">
<div class="flex flex-col gap-8 px-4 py-2 pb-12">
<Section label="Metadata">
{#snippet action()}
{#if customUi?.settingsPanel?.metadata?.disableMute !== true}
@@ -1065,13 +1067,11 @@
<MetadataGen
aiId="create-script-summary-input"
aiDescription="Summary / Title of the new script"
label="Summary"
bind:content={script.summary}
lang={script.language}
code={script.content}
promptConfigName="summary"
generateOnAppear
on:change={() => onSummaryChange(script.summary)}
onChange={() => onSummaryChange(script.summary)}
elementProps={{
type: 'text',
placeholder: 'Short summary to be displayed when listed'
@@ -1101,7 +1101,6 @@
<Label label="Description">
<MetadataGen
bind:content={script.description}
lang={script.language}
code={script.content}
promptConfigName="description"
elementType="textarea"
@@ -1214,7 +1213,7 @@
</div>
</TabContent>
<TabContent value="runtime">
<div class="flex flex-col gap-8 px-4 py-2">
<div class="flex flex-col gap-8 px-4 py-2 pb-12">
<Section label="Worker group tag (queue)">
{#snippet header()}
<Tooltip
@@ -1305,27 +1304,28 @@
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
checked={Boolean(script.cache_ttl)}
on:change={() => {
if (script.cache_ttl && script.cache_ttl != undefined) {
script.cache_ttl = undefined
} else {
script.cache_ttl = 300
}
}}
options={{
right: 'Cache the results for each possible inputs'
}}
bind:checked={
() => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)
}
options={{ right: 'Cache the results for each possible inputs' }}
/>
{#if Boolean(script.cache_ttl)}
<span class="text-xs font-semibold text-emphasis leading-none mt-2">
How long to the keep cache valid
</span>
{#if script.cache_ttl}
{#if script.cache_ttl}
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
<div class="-mt-5">
<SecondsInput bind:seconds={script.cache_ttl} />
{:else}
<SecondsInput disabled />
{/if}
</div>
<Toggle
size="2xs"
bind:checked={
() => script.cache_ignore_s3_path,
(v) => (script.cache_ignore_s3_path = v || undefined)
}
options={{
right: 'Ignore S3 Object paths for caching purposes',
rightTooltip:
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
}}
/>
{/if}
</div>
</Section>
@@ -1759,7 +1759,6 @@
/>
</div>
{/if}
</div>
{#if $enterpriseLicense && initialPath != ''}
@@ -23,7 +23,7 @@
import Modal from './common/modal/Modal.svelte'
import DiffEditor from './DiffEditor.svelte'
import {
Clipboard,
Copy,
CornerDownLeft,
ExternalLink,
Github,
@@ -556,7 +556,7 @@
<Button
color="light"
startIcon={{ icon: Clipboard }}
startIcon={{ icon: Copy }}
iconOnly
on:click={() => copyToClipboard(collabUrl())}
/>
+28 -19
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { AlertTriangle, ChevronRight } from 'lucide-svelte'
import { ChevronRight } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
import EEOnly from './EEOnly.svelte'
interface Props {
label?: string | undefined
@@ -19,10 +20,13 @@
animate?: boolean
breakAll?: boolean
class?: string | undefined
description?: string | undefined
initiallyCollapsed?: boolean
header?: import('svelte').Snippet
action?: import('svelte').Snippet
badge?: import('svelte').Snippet
children?: import('svelte').Snippet
labelExtra?: import('svelte').Snippet
}
let {
@@ -34,21 +38,24 @@
wrapperClass = '',
headerClass = '',
collapsable = false,
collapsed = $bindable(true),
initiallyCollapsed = true,
collapsed = $bindable(initiallyCollapsed),
headless = false,
animate = false,
breakAll = false,
class: clazz = undefined,
description = undefined,
header,
action,
badge,
children
children,
labelExtra
}: Props = $props()
</script>
<div class={twMerge('w-full flex flex-col', wrapperClass)}>
{#if !headless}
<div class="flex flex-row justify-between items-center mb-2">
<div class="flex flex-row justify-between items-center">
<h2
class={twMerge(
'text-emphasis flex flex-row items-center gap-1',
@@ -59,18 +66,16 @@
>
{#if collapsable}
<button class="flex items-center gap-1" onclick={() => (collapsed = !collapsed)}>
<ChevronRight
size={16}
class={twMerge(
'transition',
collapsed ? '' : 'rotate-90',
animate ? 'duration-200' : 'duration-0'
)}
/>
{label}
{@render labelExtra?.()}
<ChevronRight
size={14}
class={twMerge('transition duration-200', collapsed ? '' : 'rotate-90')}
/>
</button>
{:else}
{label}
{@render labelExtra?.()}
{/if}
{@render header?.()}
@@ -79,10 +84,7 @@
{/if}
{#if eeOnly}
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap ml-8">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
<EEOnly />
{/if}
{/if}
</h2>
@@ -94,10 +96,17 @@
{/if}
{#if !collapsable || !collapsed}
<div
class={twMerge('grow min-h-0', clazz)}
transition:slide={animate ? { duration: 200 } : { duration: 0 }}
class={'grow min-h-0 '}
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
>
{@render children?.()}
{#if description}
<div class="text-xs text-primary mt-1">{description}</div>
{/if}
<div class={twMerge('flex flex-col gap-6 h-full', description ? 'mt-4' : 'mt-2')}>
<div class={twMerge('grow min-h-0', clazz)}>
{@render children?.()}
</div>
</div>
</div>
{/if}
</div>

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