diff --git a/.claude/skills/adding-a-trigger/SKILL.md b/.claude/skills/adding-a-trigger/SKILL.md index c38cc1516d..7d8643b862 100644 --- a/.claude/skills/adding-a-trigger/SKILL.md +++ b/.claude/skills/adding-a-trigger/SKILL.md @@ -111,7 +111,9 @@ Regenerate frontend client: `npm run generate-backend-client` from `frontend/`. **`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query. -**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). +**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). The block re-uses the `trigger_ignore_keys` variable so the new kind automatically participates in fork-export stripping (`mode` field is omitted when the source workspace is a fork — keeps fork→parent merges from flipping the parent's enabled state). + +**Fork cloning (`clone_triggers_and_schedules` in workspaces.rs)** — add an `INSERT INTO {kind}_trigger ... SELECT ...` block that copies all rows from the parent workspace, forcing `mode = 'disabled'::TRIGGER_MODE`. Always runs at fork creation; forgetting this means users can't carry `{kind}` triggers into their forks. ## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7239633716..427eba2a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.694.0](https://github.com/windmill-labs/windmill/compare/v1.693.4...v1.694.0) (2026-05-01) + + +### Features + +* ansible delegate_to_git_repo install_requirements, dynamic fields, --limit ([#8997](https://github.com/windmill-labs/windmill/issues/8997)) ([96324ea](https://github.com/windmill-labs/windmill/commit/96324ea5aed4054d33895102ec9313f5dadc77a2)) +* **cli:** wmill-lock.yaml auto-fill + --rehash-only + path-prefix dedup ([#8978](https://github.com/windmill-labs/windmill/issues/8978)) ([0b959b8](https://github.com/windmill-labs/windmill/commit/0b959b8ec61b24d861c5a10a9242a7a0e6013707)) +* **forks:** handle triggers and schedules in workspace forks ([#8976](https://github.com/windmill-labs/windmill/issues/8976)) ([d60dd74](https://github.com/windmill-labs/windmill/commit/d60dd745e49853bb130b139f300fc0f2ab8ebe39)) +* support assigning a worker tag to app inline scripts ([#9002](https://github.com/windmill-labs/windmill/issues/9002)) ([0c22f52](https://github.com/windmill-labs/windmill/commit/0c22f52b46c56d3577309e37c1e81a1a1feb9b7c)) + + +### Bug Fixes + +* **cli:** only preserve case for raw-app runnableIds, not app/flow summaries ([#9000](https://github.com/windmill-labs/windmill/issues/9000)) ([5d5b853](https://github.com/windmill-labs/windmill/commit/5d5b853f70a73453f63d14edcd5d2fac8e3d804c)) +* distinguish AlreadyCompleted from execution failure on OTLP job span ([#9004](https://github.com/windmill-labs/windmill/issues/9004)) ([70a5880](https://github.com/windmill-labs/windmill/commit/70a5880d3619edece4ce67a00407ee0d2d523469)) +* nested-restart iteration count for step-id collisions across subflow boundaries ([#9003](https://github.com/windmill-labs/windmill/issues/9003)) ([ad9f1fa](https://github.com/windmill-labs/windmill/commit/ad9f1fa4541f2eefb0b013bac42cd626af424852)) +* omit empty assets array on scripts and raw app inline scripts ([#9006](https://github.com/windmill-labs/windmill/issues/9006)) ([419bc4b](https://github.com/windmill-labs/windmill/commit/419bc4b1757a7c20c2b7aa9b7d3b02e3515f934a)) +* pair PG arg type with actual Rust binding to keep query_typed_raw safe ([#8999](https://github.com/windmill-labs/windmill/issues/8999)) ([aedf369](https://github.com/windmill-labs/windmill/commit/aedf3691744748a307976ef01ea7f63b0961fad4)) +* route email trigger path through standard info channel ([#8996](https://github.com/windmill-labs/windmill/issues/8996)) ([2141128](https://github.com/windmill-labs/windmill/commit/21411282bb4a0442046bf71fdc7ea012fa2c3d3d)) +* surface scope errors as 403 and show real message in CLI ([#8953](https://github.com/windmill-labs/windmill/issues/8953)) ([66db873](https://github.com/windmill-labs/windmill/commit/66db873651a04b0701d8231fbf11ab973c3fc69b)) +* use otel.status_message for OTLP Status.message on failed jobs ([#8995](https://github.com/windmill-labs/windmill/issues/8995)) ([9cb777a](https://github.com/windmill-labs/windmill/commit/9cb777a6b6e969696cf9beade1cf07c86967dafc)) + ## [1.693.4](https://github.com/windmill-labs/windmill/compare/v1.693.3...v1.693.4) (2026-04-30) diff --git a/backend/.sqlx/query-0b347b021123e66ffb6b7eb690f7619daf34795410505609a1ff3bf0be953550.json b/backend/.sqlx/query-0b347b021123e66ffb6b7eb690f7619daf34795410505609a1ff3bf0be953550.json new file mode 100644 index 0000000000..b3f9ac793e --- /dev/null +++ b/backend/.sqlx/query-0b347b021123e66ffb6b7eb690f7619daf34795410505609a1ff3bf0be953550.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO sqs_trigger (\n path, queue_url, aws_resource_path, message_attributes, script_path,\n is_flow, workspace_id, edited_by, edited_at, extra_perms, error,\n server_id, last_server_ping, aws_auth_resource_type, error_handler_path,\n error_handler_args, retry, mode, permissioned_as, labels\n )\n SELECT\n path, queue_url, aws_resource_path, message_attributes, script_path,\n is_flow, $1, edited_by, edited_at, extra_perms, NULL,\n NULL, NULL, aws_auth_resource_type, error_handler_path,\n error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM sqs_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0b347b021123e66ffb6b7eb690f7619daf34795410505609a1ff3bf0be953550" +} diff --git a/backend/.sqlx/query-149b645af2324fc3140bf2662e75e579dcc8b928be3a2cc051e62aa2ddc09b1e.json b/backend/.sqlx/query-149b645af2324fc3140bf2662e75e579dcc8b928be3a2cc051e62aa2ddc09b1e.json new file mode 100644 index 0000000000..bd8d731ebe --- /dev/null +++ b/backend/.sqlx/query-149b645af2324fc3140bf2662e75e579dcc8b928be3a2cc051e62aa2ddc09b1e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO schedule (\n workspace_id, path, edited_by, edited_at, schedule, enabled, script_path,\n args, extra_perms, is_flow, email, error, timezone, on_failure,\n on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry,\n summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args,\n cron_version, description, dynamic_skip, permissioned_as, labels\n )\n SELECT\n $1, path, edited_by, edited_at, schedule, FALSE, script_path,\n args, extra_perms, is_flow, email, error, timezone, on_failure,\n on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry,\n summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args,\n cron_version, description, dynamic_skip, permissioned_as, labels\n FROM schedule WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "149b645af2324fc3140bf2662e75e579dcc8b928be3a2cc051e62aa2ddc09b1e" +} diff --git a/backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json b/backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json new file mode 100644 index 0000000000..06a3ee417e --- /dev/null +++ b/backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO azure_trigger (\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters, push_auth_config, path, script_path,\n is_flow, workspace_id, edited_by, email, edited_at, extra_perms, server_id,\n last_server_ping, error, mode, permissioned_as, error_handler_path,\n error_handler_args, retry, labels\n )\n SELECT\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters, push_auth_config, path, script_path,\n is_flow, $1, edited_by, email, edited_at, extra_perms, NULL,\n NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path,\n error_handler_args, retry, labels\n FROM azure_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c" +} diff --git a/backend/.sqlx/query-1a66a5a9c2b7b75e783c59b4c2ed3f7f84adc67318fd0bc5cdee78b2c406ae81.json b/backend/.sqlx/query-1a66a5a9c2b7b75e783c59b4c2ed3f7f84adc67318fd0bc5cdee78b2c406ae81.json new file mode 100644 index 0000000000..39bfb936d5 --- /dev/null +++ b/backend/.sqlx/query-1a66a5a9c2b7b75e783c59b4c2ed3f7f84adc67318fd0bc5cdee78b2c406ae81.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO email_trigger (\n path, local_part, workspaced_local_part, script_path, is_flow,\n workspace_id, edited_by, edited_at, extra_perms, error_handler_path,\n error_handler_args, retry, mode, permissioned_as, labels\n )\n SELECT\n path, local_part, workspaced_local_part, script_path, is_flow,\n $1, edited_by, edited_at, extra_perms, error_handler_path,\n error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM email_trigger\n WHERE workspace_id = $2\n AND (workspaced_local_part IS TRUE OR $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "1a66a5a9c2b7b75e783c59b4c2ed3f7f84adc67318fd0bc5cdee78b2c406ae81" +} diff --git a/backend/.sqlx/query-22699056871c6306f689d4bc6f9a67070baa4ff83de3b4a04c4d8d6054a59319.json b/backend/.sqlx/query-22699056871c6306f689d4bc6f9a67070baa4ff83de3b4a04c4d8d6054a59319.json new file mode 100644 index 0000000000..fd83b5c59d --- /dev/null +++ b/backend/.sqlx/query-22699056871c6306f689d4bc6f9a67070baa4ff83de3b4a04c4d8d6054a59319.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM schedule WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "22699056871c6306f689d4bc6f9a67070baa4ff83de3b4a04c4d8d6054a59319" +} diff --git a/backend/.sqlx/query-2e67fa50d5d66cbca4ef74111f0ff9b51a6168adad3775f8d6cf40d02cb29d14.json b/backend/.sqlx/query-2e67fa50d5d66cbca4ef74111f0ff9b51a6168adad3775f8d6cf40d02cb29d14.json new file mode 100644 index 0000000000..7fd28dec83 --- /dev/null +++ b/backend/.sqlx/query-2e67fa50d5d66cbca4ef74111f0ff9b51a6168adad3775f8d6cf40d02cb29d14.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO gcp_trigger (\n gcp_resource_path, topic_id, subscription_id, delivery_type,\n delivery_config, path, script_path, is_flow, workspace_id, edited_by,\n edited_at, extra_perms, server_id, last_server_ping, error,\n subscription_mode, error_handler_path, error_handler_args, retry,\n auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels\n )\n SELECT\n gcp_resource_path, topic_id, subscription_id, delivery_type,\n delivery_config, path, script_path, is_flow, $1, edited_by,\n edited_at, extra_perms, NULL, NULL, NULL,\n subscription_mode, error_handler_path, error_handler_args, retry,\n auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM gcp_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2e67fa50d5d66cbca4ef74111f0ff9b51a6168adad3775f8d6cf40d02cb29d14" +} diff --git a/backend/.sqlx/query-4d64c962e219f7cda8b93c3875acc78a4e17aae2c702ec1af6c3a4ae3f1e2716.json b/backend/.sqlx/query-4d64c962e219f7cda8b93c3875acc78a4e17aae2c702ec1af6c3a4ae3f1e2716.json new file mode 100644 index 0000000000..c0eb186881 --- /dev/null +++ b/backend/.sqlx/query-4d64c962e219f7cda8b93c3875acc78a4e17aae2c702ec1af6c3a4ae3f1e2716.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO nats_trigger (\n path, nats_resource_path, subjects, stream_name, consumer_name,\n use_jetstream, script_path, is_flow, workspace_id, edited_by, edited_at,\n extra_perms, server_id, last_server_ping, error, error_handler_path,\n error_handler_args, retry, mode, permissioned_as, labels\n )\n SELECT\n path, nats_resource_path, subjects, stream_name, consumer_name,\n use_jetstream, script_path, is_flow, $1, edited_by, edited_at,\n extra_perms, NULL, NULL, NULL, error_handler_path,\n error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM nats_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4d64c962e219f7cda8b93c3875acc78a4e17aae2c702ec1af6c3a4ae3f1e2716" +} diff --git a/backend/.sqlx/query-5fd769bde29e88eb43f5dc0ecd7f4a1fe20d216d818d69eea4ddbaace5233137.json b/backend/.sqlx/query-5fd769bde29e88eb43f5dc0ecd7f4a1fe20d216d818d69eea4ddbaace5233137.json new file mode 100644 index 0000000000..c38731ff6d --- /dev/null +++ b/backend/.sqlx/query-5fd769bde29e88eb43f5dc0ecd7f4a1fe20d216d818d69eea4ddbaace5233137.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO websocket_trigger (\n path, url, script_path, is_flow, workspace_id, edited_by, edited_at,\n extra_perms, server_id, last_server_ping, error, filters, initial_messages,\n url_runnable_args, can_return_message, error_handler_path, error_handler_args,\n retry, can_return_error_result, mode, permissioned_as, filter_logic, labels,\n heartbeat\n )\n SELECT\n path, url, script_path, is_flow, $1, edited_by, edited_at,\n extra_perms, NULL, NULL, NULL, filters, initial_messages,\n url_runnable_args, can_return_message, error_handler_path, error_handler_args,\n retry, can_return_error_result, 'disabled'::TRIGGER_MODE, permissioned_as, filter_logic, labels,\n heartbeat\n FROM websocket_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5fd769bde29e88eb43f5dc0ecd7f4a1fe20d216d818d69eea4ddbaace5233137" +} diff --git a/backend/.sqlx/query-fe464b8b3ade86743d82c5e3fb14f457e07f07e44c7b693d5d755899d4210dee.json b/backend/.sqlx/query-aa0a2f90d15a642ad3caaa3876d9cb4a5391ff8663da61b48bfeb73bfa005bbe.json similarity index 85% rename from backend/.sqlx/query-fe464b8b3ade86743d82c5e3fb14f457e07f07e44c7b693d5d755899d4210dee.json rename to backend/.sqlx/query-aa0a2f90d15a642ad3caaa3876d9cb4a5391ff8663da61b48bfeb73bfa005bbe.json index 71bb00c25e..7e985b63ec 100644 --- a/backend/.sqlx/query-fe464b8b3ade86743d82c5e3fb14f457e07f07e44c7b693d5d755899d4210dee.json +++ b/backend/.sqlx/query-aa0a2f90d15a642ad3caaa3876d9cb4a5391ff8663da61b48bfeb73bfa005bbe.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(\n SELECT 1\n FROM http_trigger\n WHERE\n ((workspaced_route IS TRUE AND workspace_id || '/' || route_path_key = $1)\n OR (workspaced_route IS FALSE AND route_path_key = $1))\n AND http_method = $2\n AND ($3::TEXT IS NULL OR path != $3)\n )\n ", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM http_trigger\n WHERE\n ((workspaced_route IS TRUE AND workspace_id || '/' || route_path_key = $1)\n OR (workspaced_route IS FALSE AND route_path_key = $1))\n AND http_method = $2\n AND ($3::TEXT IS NULL OR NOT (workspace_id = $4 AND path = $3))\n )\n ", "describe": { "columns": [ { @@ -26,6 +26,7 @@ } } }, + "Text", "Text" ] }, @@ -33,5 +34,5 @@ null ] }, - "hash": "fe464b8b3ade86743d82c5e3fb14f457e07f07e44c7b693d5d755899d4210dee" + "hash": "aa0a2f90d15a642ad3caaa3876d9cb4a5391ff8663da61b48bfeb73bfa005bbe" } diff --git a/backend/.sqlx/query-aee9bf16e37a5361f96d6b35dddffa6cc37346cdb6b38ecbf6e1530eef3a57bd.json b/backend/.sqlx/query-aee9bf16e37a5361f96d6b35dddffa6cc37346cdb6b38ecbf6e1530eef3a57bd.json new file mode 100644 index 0000000000..6a93511b46 --- /dev/null +++ b/backend/.sqlx/query-aee9bf16e37a5361f96d6b35dddffa6cc37346cdb6b38ecbf6e1530eef3a57bd.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO http_trigger (\n path, route_path, route_path_key, script_path, is_flow, workspace_id,\n edited_by, edited_at, extra_perms, authentication_method, http_method,\n static_asset_config, is_static_website, workspaced_route, wrap_body,\n raw_string, authentication_resource_path, summary, description,\n error_handler_path, error_handler_args, retry, request_type, mode,\n permissioned_as, labels\n )\n SELECT\n path, route_path, route_path_key, script_path, is_flow, $1,\n edited_by, edited_at, extra_perms, authentication_method, http_method,\n static_asset_config, is_static_website, workspaced_route, wrap_body,\n raw_string, authentication_resource_path, summary, description,\n error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE,\n permissioned_as, labels\n FROM http_trigger\n WHERE workspace_id = $2\n AND (workspaced_route IS TRUE OR $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "aee9bf16e37a5361f96d6b35dddffa6cc37346cdb6b38ecbf6e1530eef3a57bd" +} diff --git a/backend/.sqlx/query-b20e4486e2038ac8138f5d7435db8cbd78ac3d68674335db79df7fd8a4ba91fd.json b/backend/.sqlx/query-b20e4486e2038ac8138f5d7435db8cbd78ac3d68674335db79df7fd8a4ba91fd.json new file mode 100644 index 0000000000..a2dc97112e --- /dev/null +++ b/backend/.sqlx/query-b20e4486e2038ac8138f5d7435db8cbd78ac3d68674335db79df7fd8a4ba91fd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO postgres_trigger (\n path, script_path, is_flow, workspace_id, edited_by, edited_at,\n extra_perms, postgres_resource_path, error, server_id, last_server_ping,\n replication_slot_name, publication_name, error_handler_path,\n error_handler_args, retry, mode, permissioned_as, labels\n )\n SELECT\n path, script_path, is_flow, $1, edited_by, edited_at,\n extra_perms, postgres_resource_path, NULL, NULL, NULL,\n replication_slot_name, publication_name, error_handler_path,\n error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM postgres_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b20e4486e2038ac8138f5d7435db8cbd78ac3d68674335db79df7fd8a4ba91fd" +} diff --git a/backend/.sqlx/query-c8c8c457ee80125938af97993c4ca759b66507071163f8d98a778f417b930413.json b/backend/.sqlx/query-c8c8c457ee80125938af97993c4ca759b66507071163f8d98a778f417b930413.json new file mode 100644 index 0000000000..ed5eb44ddc --- /dev/null +++ b/backend/.sqlx/query-c8c8c457ee80125938af97993c4ca759b66507071163f8d98a778f417b930413.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mqtt_trigger (\n mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config,\n client_id, path, script_path, is_flow, workspace_id, edited_by, edited_at,\n extra_perms, server_id, last_server_ping, error, error_handler_path,\n error_handler_args, retry, mode, permissioned_as, labels\n )\n SELECT\n mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config,\n client_id, path, script_path, is_flow, $1, edited_by, edited_at,\n extra_perms, NULL, NULL, NULL, error_handler_path,\n error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM mqtt_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c8c8c457ee80125938af97993c4ca759b66507071163f8d98a778f417b930413" +} diff --git a/backend/.sqlx/query-c9c0b92c1fea9b4bdafba32da60e5579a4c2940bed63097ffec7f757d9882667.json b/backend/.sqlx/query-c9c0b92c1fea9b4bdafba32da60e5579a4c2940bed63097ffec7f757d9882667.json new file mode 100644 index 0000000000..b72f952955 --- /dev/null +++ b/backend/.sqlx/query-c9c0b92c1fea9b4bdafba32da60e5579a4c2940bed63097ffec7f757d9882667.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id, script_path, is_flow,\n workspace_id, edited_by, edited_at, extra_perms, server_id,\n last_server_ping, error, error_handler_path, error_handler_args, retry,\n mode, filters, auto_offset_reset, reset_offset, auto_commit,\n permissioned_as, filter_logic, labels\n )\n SELECT\n path, kafka_resource_path, topics, group_id, script_path, is_flow,\n $1, edited_by, edited_at, extra_perms, NULL,\n NULL, NULL, error_handler_path, error_handler_args, retry,\n 'disabled'::TRIGGER_MODE, filters, auto_offset_reset, reset_offset, auto_commit,\n permissioned_as, filter_logic, labels\n FROM kafka_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c9c0b92c1fea9b4bdafba32da60e5579a4c2940bed63097ffec7f757d9882667" +} diff --git a/backend/.sqlx/query-f57ff370f6775602d2c200d78650d65ffa5bfc5f10e8bd2a3162894c93283259.json b/backend/.sqlx/query-f57ff370f6775602d2c200d78650d65ffa5bfc5f10e8bd2a3162894c93283259.json new file mode 100644 index 0000000000..93f93b921d --- /dev/null +++ b/backend/.sqlx/query-f57ff370f6775602d2c200d78650d65ffa5bfc5f10e8bd2a3162894c93283259.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_workspace_id IS NOT NULL FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f57ff370f6775602d2c200d78650d65ffa5bfc5f10e8bd2a3162894c93283259" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d5c0b09b12..ba2f0f37b8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -7681,14 +7681,14 @@ dependencies = [ [[package]] name = "json-patch" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -16020,7 +16020,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-nats", @@ -16101,7 +16101,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.693.4" +version = "1.694.0" dependencies = [ "async-trait", "aws-config", @@ -16125,7 +16125,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16138,7 +16138,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "argon2", @@ -16281,7 +16281,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16304,7 +16304,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16317,7 +16317,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16343,7 +16343,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.693.4" +version = "1.694.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16353,7 +16353,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16370,7 +16370,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16392,7 +16392,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16415,7 +16415,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16431,7 +16431,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16452,7 +16452,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16473,7 +16473,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16487,7 +16487,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-nats", @@ -16519,7 +16519,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16544,7 +16544,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16562,7 +16562,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16584,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16604,7 +16604,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16634,7 +16634,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16662,7 +16662,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.693.4" +version = "1.694.0" dependencies = [ "lazy_static", "serde", @@ -16674,7 +16674,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.693.4" +version = "1.694.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16699,7 +16699,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16713,7 +16713,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.693.4" +version = "1.694.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16746,7 +16746,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.693.4" +version = "1.694.0" dependencies = [ "chrono", "lazy_static", @@ -16760,7 +16760,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16779,7 +16779,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.693.4" +version = "1.694.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16880,7 +16880,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.693.4" +version = "1.694.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16899,7 +16899,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.693.4" +version = "1.694.0" dependencies = [ "regex", "serde", @@ -16914,7 +16914,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16938,7 +16938,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "futures", @@ -16955,7 +16955,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.693.4" +version = "1.694.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16971,7 +16971,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -16992,7 +16992,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17023,7 +17023,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "arc-swap", @@ -17048,7 +17048,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-stream", @@ -17082,7 +17082,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "futures", @@ -17100,7 +17100,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.693.4" +version = "1.694.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -17109,7 +17109,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -17121,7 +17121,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde_json", @@ -17133,7 +17133,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "gosyn", @@ -17145,7 +17145,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -17157,7 +17157,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde_json", @@ -17169,7 +17169,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "nu-parser", @@ -17180,7 +17180,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17191,7 +17191,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17203,7 +17203,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17214,7 +17214,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-recursion", @@ -17236,7 +17236,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde_json", @@ -17248,7 +17248,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -17262,7 +17262,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17279,7 +17279,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -17292,7 +17292,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde", @@ -17304,7 +17304,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -17322,7 +17322,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17338,7 +17338,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17354,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde", @@ -17365,7 +17365,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-recursion", @@ -17402,7 +17402,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "const_format", @@ -17440,7 +17440,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.693.4" +version = "1.694.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17451,7 +17451,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-recursion", @@ -17481,7 +17481,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17505,7 +17505,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17538,7 +17538,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17571,7 +17571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17591,7 +17591,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17625,7 +17625,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17661,7 +17661,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17684,7 +17684,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17708,7 +17708,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-nats", @@ -17732,7 +17732,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17767,7 +17767,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17795,7 +17795,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-trait", @@ -17818,7 +17818,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17837,7 +17837,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-once-cell", @@ -17949,7 +17949,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.693.4" +version = "1.694.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a95d6d1f37..e39ea3eb93 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.693.4" +version = "1.694.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.693.4" +version = "1.694.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 88a634d48e..f48c738f28 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -26184ab7a4aadfc529dcedf038aa08d36c7ad381 +967f961f0a88b027d894aebd03977181129477a8 diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 05d6baf815..68cb9c8580 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.693.4" +version = "1.694.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.693.4" +version = "1.694.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.693.4" +version = "1.694.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.693.4" +version = "1.694.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 39d5655d45..86603edffd 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.693.4" +version = "1.694.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 170bf9573b..a791060a2c 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -960,6 +960,7 @@ export function main() { // Dedicated Worker Protocol Tests // ============================================================================ +#[cfg(feature = "private")] mod dedicated_worker_protocol { use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; @@ -1668,6 +1669,7 @@ export function main(x?: number): string { // Deno Dedicated Worker Protocol Tests // ============================================================================ +#[cfg(feature = "private")] mod dedicated_worker_protocol_deno { use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 81fe2c3b55..dda3ec7082 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -11,7 +11,7 @@ use windmill_test_utils::*; // Dedicated Worker Protocol Tests (Python) // ============================================================================ -#[cfg(feature = "python")] +#[cfg(all(feature = "python", feature = "private"))] mod dedicated_worker_protocol_python { use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index deef1331ea..5cb9d6c2ec 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -1951,6 +1951,7 @@ CREATE TABLE wm_pg_arg_combo_test.enumtbl (c wm_pg_arg_combo_test.color); .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, + tag: None, })) .arg("database", db_arg.clone()) .run_until_complete(&db, false, port) @@ -2405,6 +2406,7 @@ SELECT 'price: $5' AS lbl, $5::int + $50::int AS sum"# .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, + tag: None, })) .arg("database", db_arg.clone()); for (k, v) in args.as_object().unwrap() { @@ -2459,6 +2461,7 @@ async fn test_postgresql_no_named_statements_after_typed_args( .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, + tag: None, })) .arg("database", db_arg.clone()) }; @@ -2547,6 +2550,7 @@ async fn test_postgresql_prepare_fallback_for_unrecognised_arg_t( .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, + tag: None, })) .arg("database", db_arg.clone()) }; @@ -2642,6 +2646,7 @@ async fn test_postgresql_custom_types_on_cached_connection( .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, + tag: None, })) .arg("database", db_arg.clone()) }; @@ -2733,6 +2738,7 @@ async fn test_postgresql_set_role_does_not_leak_across_cached_connection( .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, + tag: None, })) .arg("database", db_arg.clone()) }; diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 34474bd35f..d4c448e934 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -678,7 +678,6 @@ pub async fn resolve_opt_job_authed( path, method, ) { - BRUTE_FORCE_COUNTER.increment().await; return Err((err, parts)); } } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 4e230c019b..c5b9b5adc7 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -220,7 +220,7 @@ where } if is_scoped_token { - return Err(Error::NotAuthorized(format!( + return Err(Error::PermissionDenied(format!( "Required scope: {}", required_scope.as_string() ))); diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 42bca309c7..6df2f74ae8 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -461,7 +461,7 @@ pub fn check_route_access( if token_scopes.iter().any(|s| s.starts_with("mcp:")) { return Ok(()); } - return Err(Error::NotAuthorized( + return Err(Error::PermissionDenied( "Access denied. Required scope: mcp:*".to_string(), )); } @@ -506,7 +506,7 @@ pub fn check_route_access( format!("{}:{}", required_domain.as_str(), required_action.as_str()) }; - Err(Error::NotAuthorized(format!( + Err(Error::PermissionDenied(format!( "Access denied. Required scope: {}", scope_display ))) diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index f9a24d99d8..8de72d98f8 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -855,6 +855,38 @@ pub async fn set_enabled( let mut tx = user_db.begin(&authed).await?; let path = path.to_path(); check_scopes(&authed, || format!("schedules:write:{}", path))?; + + // Block enabling a schedule in a fork when the parent has the same path + // (regardless of parent's enabled flag), unless force=true. Two enabled + // crons fire in lockstep; even when the parent is currently disabled the + // user is likely to re-enable it later, at which point both fire — better + // to surface that risk at every fork-side enable. There's no namespacing + // fix for schedules (Phase 3 doesn't help cron); the user has to confirm + // or point the script at fork-only side effects. + if payload.enabled && !payload.force { + let parent_id: Option = sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + if let Some(parent_id) = parent_id { + let exists: Option = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM schedule WHERE workspace_id = $1 AND path = $2)", + &parent_id, + path, + ) + .fetch_one(&mut *tx) + .await?; + if exists == Some(true) { + return Err(Error::BadRequest(format!( + "fork-conflict:schedule:{}", + parent_id + ))); + } + } + } // email is still written for backwards compat with old workers that don't know about permissioned_as let schedule_o = sqlx::query_as!( Schedule, @@ -1281,6 +1313,11 @@ pub use windmill_queue::schedule::clear_schedule; #[derive(Deserialize)] pub struct SetEnabled { pub enabled: bool, + /// Bypass the parent-state warning when enabling a schedule in a fork + /// whose parent has the same path enabled. The frontend sets this after + /// the user confirms the duplicate-firing dialog. + #[serde(default)] + pub force: bool, } // #[derive(Deserialize)] diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 352b8df632..9ca9a0dd84 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -30,6 +30,7 @@ use uuid::Uuid; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::db::UserDB; +use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::users::username_to_permissioned_as; use windmill_common::variables::{ build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, @@ -3857,6 +3858,264 @@ async fn clone_workspace_data( Ok(()) } +/// Clone every trigger and schedule from the parent workspace, forcing +/// `mode='disabled'` / `enabled=false`. Always runs at fork creation — +/// disabled rows have no side effects, so cloning them is safe and lets +/// users re-enable selectively in the fork. Listener identifiers +/// (group_id, replication_slot_name, subscription_name, …) are copied +/// verbatim — the runtime suffix that prevents the fork from competing with +/// the parent ships in a follow-up PR. +async fn clone_triggers_and_schedules( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + r#"INSERT INTO schedule ( + workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, + args, extra_perms, is_flow, email, error, timezone, on_failure, + on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args, + on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, + summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args, + cron_version, description, dynamic_skip, permissioned_as, labels + ) + SELECT + $1, path, edited_by, edited_at, schedule, FALSE, script_path, + args, extra_perms, is_flow, email, error, timezone, on_failure, + on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args, + on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, + summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args, + cron_version, description, dynamic_skip, permissioned_as, labels + FROM schedule WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + // Skip non-workspaced HTTP triggers: their URL has no workspace prefix, so + // a clone would collide with the parent's row at runtime (matchit::Router + // silently drops one of two duplicates) and `route_path_key_exists` would + // also fail to spot the cross-workspace conflict cleanly. The instance + // settings `CLOUD_HOSTED` and `HTTP_ROUTE_WORKSPACED_ROUTE` force every + // route to be workspace-prefixed regardless of the column, so when either + // is on we clone everything. + let force_workspaced = + *CLOUD_HOSTED || HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + sqlx::query!( + r#"INSERT INTO http_trigger ( + path, route_path, route_path_key, script_path, is_flow, workspace_id, + edited_by, edited_at, extra_perms, authentication_method, http_method, + static_asset_config, is_static_website, workspaced_route, wrap_body, + raw_string, authentication_resource_path, summary, description, + error_handler_path, error_handler_args, retry, request_type, mode, + permissioned_as, labels + ) + SELECT + path, route_path, route_path_key, script_path, is_flow, $1, + edited_by, edited_at, extra_perms, authentication_method, http_method, + static_asset_config, is_static_website, workspaced_route, wrap_body, + raw_string, authentication_resource_path, summary, description, + error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE, + permissioned_as, labels + FROM http_trigger + WHERE workspace_id = $2 + AND (workspaced_route IS TRUE OR $3)"#, + target_workspace_id, + source_workspace_id, + force_workspaced, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO websocket_trigger ( + path, url, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, server_id, last_server_ping, error, filters, initial_messages, + url_runnable_args, can_return_message, error_handler_path, error_handler_args, + retry, can_return_error_result, mode, permissioned_as, filter_logic, labels, + heartbeat + ) + SELECT + path, url, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, NULL, NULL, NULL, filters, initial_messages, + url_runnable_args, can_return_message, error_handler_path, error_handler_args, + retry, can_return_error_result, 'disabled'::TRIGGER_MODE, permissioned_as, filter_logic, labels, + heartbeat + FROM websocket_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO kafka_trigger ( + path, kafka_resource_path, topics, group_id, script_path, is_flow, + workspace_id, edited_by, edited_at, extra_perms, server_id, + last_server_ping, error, error_handler_path, error_handler_args, retry, + mode, filters, auto_offset_reset, reset_offset, auto_commit, + permissioned_as, filter_logic, labels + ) + SELECT + path, kafka_resource_path, topics, group_id, script_path, is_flow, + $1, edited_by, edited_at, extra_perms, NULL, + NULL, NULL, error_handler_path, error_handler_args, retry, + 'disabled'::TRIGGER_MODE, filters, auto_offset_reset, reset_offset, auto_commit, + permissioned_as, filter_logic, labels + FROM kafka_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO nats_trigger ( + path, nats_resource_path, subjects, stream_name, consumer_name, + use_jetstream, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, server_id, last_server_ping, error, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, nats_resource_path, subjects, stream_name, consumer_name, + use_jetstream, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, NULL, NULL, NULL, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM nats_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO postgres_trigger ( + path, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, postgres_resource_path, error, server_id, last_server_ping, + replication_slot_name, publication_name, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, postgres_resource_path, NULL, NULL, NULL, + replication_slot_name, publication_name, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM postgres_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO mqtt_trigger ( + mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config, + client_id, path, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, server_id, last_server_ping, error, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config, + client_id, path, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, NULL, NULL, NULL, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM mqtt_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO sqs_trigger ( + path, queue_url, aws_resource_path, message_attributes, script_path, + is_flow, workspace_id, edited_by, edited_at, extra_perms, error, + server_id, last_server_ping, aws_auth_resource_type, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, queue_url, aws_resource_path, message_attributes, script_path, + is_flow, $1, edited_by, edited_at, extra_perms, NULL, + NULL, NULL, aws_auth_resource_type, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM sqs_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO gcp_trigger ( + gcp_resource_path, topic_id, subscription_id, delivery_type, + delivery_config, path, script_path, is_flow, workspace_id, edited_by, + edited_at, extra_perms, server_id, last_server_ping, error, + subscription_mode, error_handler_path, error_handler_args, retry, + auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels + ) + SELECT + gcp_resource_path, topic_id, subscription_id, delivery_type, + delivery_config, path, script_path, is_flow, $1, edited_by, + edited_at, extra_perms, NULL, NULL, NULL, + subscription_mode, error_handler_path, error_handler_args, retry, + auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM gcp_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO azure_trigger ( + azure_resource_path, azure_mode, scope_resource_id, topic_name, + subscription_name, event_type_filters, push_auth_config, path, script_path, + is_flow, workspace_id, edited_by, email, edited_at, extra_perms, server_id, + last_server_ping, error, mode, permissioned_as, error_handler_path, + error_handler_args, retry, labels + ) + SELECT + azure_resource_path, azure_mode, scope_resource_id, topic_name, + subscription_name, event_type_filters, push_auth_config, path, script_path, + is_flow, $1, edited_by, email, edited_at, extra_perms, NULL, + NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path, + error_handler_args, retry, labels + FROM azure_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + // Skip non-workspaced email triggers: same shape as the non-workspaced + // HTTP route case — a clone would share the same `local_part@domain` + // address as the parent, and incoming mail would arbitrarily land in one + // or the other. CLOUD_HOSTED scopes email lookup by workspace_id natively, + // so on cloud we clone everything. + sqlx::query!( + r#"INSERT INTO email_trigger ( + path, local_part, workspaced_local_part, script_path, is_flow, + workspace_id, edited_by, edited_at, extra_perms, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, local_part, workspaced_local_part, script_path, is_flow, + $1, edited_by, edited_at, extra_perms, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM email_trigger + WHERE workspace_id = $2 + AND (workspaced_local_part IS TRUE OR $3)"#, + target_workspace_id, + source_workspace_id, + *CLOUD_HOSTED, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn update_workspace_settings( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -4724,6 +4983,12 @@ async fn create_workspace_fork( // Clone all data from the parent workspace using Rust implementation clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + // Clone triggers and schedules unconditionally, always with mode='disabled' / + // enabled=false. Disabled rows have no side effects (no listener + // attaches, no cron fires) so this is safe by construction. The user + // re-enables in the fork, with parent-conflict warnings on enable. + clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?; + // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d03de1e61c..d47c0d2500 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.693.4 + version: 1.694.0 title: Windmill API contact: @@ -13217,6 +13217,11 @@ paths: properties: enabled: type: boolean + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + schedule in a fork whose parent has the same path enabled. required: - enabled @@ -13663,6 +13668,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -13830,6 +13840,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -14030,6 +14045,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -14271,6 +14291,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -14466,6 +14491,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -15254,6 +15284,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -15449,6 +15484,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -15703,6 +15743,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -16249,6 +16294,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -16475,6 +16525,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 0704501953..a5e9146ddb 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -255,6 +255,8 @@ pub struct PolicyTriggerableInputs { delete_after_secs: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] sensitive_inputs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + tag: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -2090,6 +2092,11 @@ async fn execute_component( let path = path.to_path(); let (arc_policy, policy): (Arc, Policy); let policy_triggerables_default = Default::default(); + // Preview mode means the request was issued from the editor; the editing + // user is already trusted by the policy check, so client-supplied `tag` + // on the inline script is honored. In any other case we must read the + // tag from the deployed policy and ignore the request body. + let is_preview = payload.force_viewer_static_fields.is_some(); // Two cases here: // 1. The component is executed from the editor (i.e. in "preview" mode), then: @@ -2119,6 +2126,7 @@ async fn execute_component( allow_user_resources: force_viewer_allow_user_resources.unwrap_or_default(), delete_after_secs: force_viewer_delete_after_secs, sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(), + tag: None, }, ), // 2. "run" mode. @@ -2264,18 +2272,31 @@ async fn execute_component( .map(|p| p.starts_with("flow/")) .unwrap_or(false); + // Tag for inline-script jobs is read from the deployed policy in run mode; + // only preview mode (editor) honors the client-supplied tag. This applies to + // both the `id`-bearing app_script path and the legacy `rawscript/` + // path that has no app_script entry yet. + let resolved_inline_tag = |client_tag: Option| -> Option { + if is_preview { + client_tag + } else { + policy_triggerables.tag.clone() + } + .filter(|t| !t.is_empty()) + }; let (job_payload, tag, on_behalf_of) = match (payload.path, payload.raw_code, payload.id) { // flow or script: (Some(path), None, None) => get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?, - // inline script: in "preview" mode or without entry in the `app_script` table. + // inline script: "preview" mode, or run mode without an entry in the + // `app_script` table (legacy `rawscript/`-keyed triggerables). (None, Some(raw_code), None) => { - let tag = raw_code.tag.clone().filter(|t| !t.is_empty()); + let tag = resolved_inline_tag(raw_code.tag.clone()); (JobPayload::Code(raw_code), tag, None) } - // inline script: in "run" mode and with an entry in the `app_script` table. + // inline script: run mode (deployed app) with an entry in `app_script`. (None, Some(RawCode { language, path, cache_ttl, tag, .. }), Some(id)) => ( JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path }, - tag.filter(|t| !t.is_empty()), + resolved_inline_tag(tag), None, ), _ => unreachable!(), diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e47587a671..91b9ab972e 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -119,6 +119,45 @@ pub fn is_none_or_false(val: &Option) -> bool { } } +/// Returns the keys to strip from trigger/schedule serialization when the +/// source workspace is a fork. Stripping these keys avoids propagating +/// fork-local operational state (enabled flag, runtime listener identifiers) +/// back to the parent workspace through the git-sync round-trip. +#[cfg(any( + feature = "http_trigger", + feature = "websocket", + feature = "postgres_trigger", + feature = "mqtt_trigger", + feature = "native_trigger", + all( + feature = "enterprise", + any( + feature = "kafka", + feature = "sqs_trigger", + feature = "gcp_trigger", + feature = "azure_trigger", + feature = "nats", + feature = "smtp", + ), + feature = "private" + ) +))] +fn fork_trigger_ignore_keys(is_fork: bool) -> Option> { + if is_fork { + Some(vec!["mode", "enabled"]) + } else { + None + } +} + +fn fork_schedule_ignore_keys(is_fork: bool) -> Option> { + if is_fork { + Some(vec!["enabled"]) + } else { + None + } +} + enum ArchiveImpl { #[cfg(feature = "zip")] Zip(async_zip::tokio::write::ZipFileWriter), @@ -415,6 +454,19 @@ pub(crate) async fn tarball_workspace( let mut tx = user_db.begin(&authed).await?; + // Source-of-truth check for fork-ness: the workspace's parent_workspace_id + // column. The wm-fork-* prefix is a creation-time naming convention that + // could in principle drift (rename, manual SQL); the column is the + // contract that matches what the conflict-warning gates read. + let is_fork: bool = sqlx::query_scalar!( + "SELECT parent_workspace_id IS NOT NULL FROM workspace WHERE id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten() + .unwrap_or(false); + let tmp_dir = TempDir::new_in(&*WINDMILL_DIR)?; let name = match archive_type.as_deref() { @@ -682,8 +734,11 @@ pub(crate) async fn tarball_workspace( .fetch_all(&mut *tx) .await?; + let schedule_ignore_keys = fork_schedule_ignore_keys(is_fork); for schedule in schedules { - let app_str = &to_string_without_metadata(&schedule, false, None).unwrap(); + let app_str = + &to_string_without_metadata(&schedule, false, schedule_ignore_keys.clone()) + .unwrap(); archive .write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path)) .await?; @@ -691,6 +746,27 @@ pub(crate) async fn tarball_workspace( } if include_triggers.unwrap_or(false) { + #[cfg(any( + feature = "http_trigger", + feature = "websocket", + feature = "postgres_trigger", + feature = "mqtt_trigger", + feature = "native_trigger", + all( + feature = "enterprise", + any( + feature = "kafka", + feature = "sqs_trigger", + feature = "gcp_trigger", + feature = "azure_trigger", + feature = "nats", + feature = "smtp", + ), + feature = "private" + ) + ))] + let trigger_ignore_keys = fork_trigger_ignore_keys(is_fork); + #[cfg(feature = "http_trigger")] { use crate::triggers::http::HttpTrigger; @@ -698,7 +774,9 @@ pub(crate) async fn tarball_workspace( let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in http_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -715,7 +793,9 @@ pub(crate) async fn tarball_workspace( let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in websocket_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -732,7 +812,9 @@ pub(crate) async fn tarball_workspace( let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in kafka_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -749,7 +831,9 @@ pub(crate) async fn tarball_workspace( let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in sqs_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -766,7 +850,9 @@ pub(crate) async fn tarball_workspace( let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in gcp_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -783,7 +869,9 @@ pub(crate) async fn tarball_workspace( let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in azure_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -801,7 +889,8 @@ pub(crate) async fn tarball_workspace( for trigger in nats_triggers { let trigger_str: &String = - &to_string_without_metadata(&trigger, false, None).unwrap(); + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -818,7 +907,9 @@ pub(crate) async fn tarball_workspace( let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in postgres_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -835,7 +926,9 @@ pub(crate) async fn tarball_workspace( let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in mqtt_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -852,7 +945,9 @@ pub(crate) async fn tarball_workspace( let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in email_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -872,11 +967,16 @@ pub(crate) async fn tarball_workspace( list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None) .await?; + let mut native_ignore_keys = vec!["webhook_token_hash"]; + if let Some(ref extra) = trigger_ignore_keys { + native_ignore_keys.extend_from_slice(extra); + } + for trigger in native_triggers { let trigger_str = &to_string_without_metadata( &trigger, false, - Some(vec!["webhook_token_hash"]), + Some(native_ignore_keys.clone()), ) .unwrap(); archive diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index 5cf0f17c02..1cacf7f594 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -13,13 +13,13 @@ use { DB, }, windmill_git_sync::DeployedObject, - windmill_trigger::{TriggerCrud, TriggerData}, + windmill_trigger::{Trigger, TriggerCrud, TriggerData}, }; #[cfg(not(feature = "private"))] #[async_trait] impl TriggerCrud for GcpTrigger { - type Trigger = (); + type Trigger = Trigger; type TriggerConfig = (); type TriggerConfigRequest = (); type TestConnectionConfig = (); diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index ad50e9811f..9aaad6e7c8 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -62,7 +62,8 @@ pub async fn route_path_key_exists( .await? .unwrap_or(false) } else { - let http_route_workspaced = HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let http_route_workspaced = + HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced; let route_path_key = if effective_workspaced { std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) @@ -70,6 +71,10 @@ pub async fn route_path_key_exists( std::borrow::Cow::Borrowed(route_path_key) }; + // Self-exclusion is by `(workspace_id, path)` not just `path`: workspace + // forks clone trigger rows verbatim, so the same trigger path can exist + // in multiple workspaces. Excluding by path alone would silently mask a + // real collision against the parent's row. sqlx::query_scalar!( r#" SELECT EXISTS( @@ -79,12 +84,13 @@ pub async fn route_path_key_exists( ((workspaced_route IS TRUE AND workspace_id || '/' || route_path_key = $1) OR (workspaced_route IS FALSE AND route_path_key = $1)) AND http_method = $2 - AND ($3::TEXT IS NULL OR path != $3) + AND ($3::TEXT IS NULL OR NOT (workspace_id = $4 AND path = $3)) ) "#, &route_path_key, http_method as &HttpMethod, - trigger_path + trigger_path, + w_id ) .fetch_one(db) .await? @@ -145,7 +151,8 @@ async fn require_admin_for_instance_wide_route( is_admin: bool, workspaced_route: Option, ) -> Result { - let http_route_workspaced = HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let http_route_workspaced = + HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced; if !is_admin && !effective_workspaced { return Err(Error::NotAuthorized( @@ -371,6 +378,10 @@ impl TriggerCrud for HttpTrigger { const ROUTE_PREFIX: &'static str = "/http_triggers"; const DEPLOYMENT_NAME: &'static str = "HTTP trigger"; const IS_ALLOWED_ON_CLOUD: bool = true; + // Cloned HTTP triggers are always workspaced (the clone filter excludes + // workspaced_route=false rows), so fork and parent live at distinct URLs + // and never collide. + const FORK_CONFLICT_ON_ENABLE: bool = false; const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[ "route_path", "route_path_key", @@ -465,7 +476,8 @@ impl TriggerCrud for HttpTrigger { let resolved_edited_by = trigger.base.resolve_edited_by(authed); let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed); - let http_route_workspaced = HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); + let http_route_workspaced = + HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); let effective_workspaced = trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index 2e1cb8bfb4..57e786b0ea 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -16,13 +16,13 @@ use { error::{Error, Result}, }, windmill_git_sync::DeployedObject, - windmill_trigger::TriggerCrud, + windmill_trigger::{Trigger, TriggerCrud}, }; #[cfg(not(feature = "private"))] #[async_trait] impl TriggerCrud for KafkaTrigger { - type Trigger = (); + type Trigger = Trigger; type TriggerConfig = (); type TriggerConfigRequest = (); type TestConnectionConfig = (); diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index f322335cb8..226bd653fd 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -16,13 +16,13 @@ use { error::{Error, Result}, }, windmill_git_sync::DeployedObject, - windmill_trigger::TriggerCrud, + windmill_trigger::{Trigger, TriggerCrud}, }; #[cfg(not(feature = "private"))] #[async_trait] impl TriggerCrud for NatsTrigger { - type Trigger = (); + type Trigger = Trigger; type TriggerConfig = (); type TriggerConfigRequest = (); type TestConnectionConfig = (); diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 3ac9281fd4..855961cf5e 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -60,6 +60,15 @@ pub trait TriggerCrud: Send + Sync + 'static { const DEPLOYMENT_NAME: &'static str; const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[]; const IS_ALLOWED_ON_CLOUD: bool; + /// Whether enabling this trigger in a fork while the parent has the same + /// path enabled is a real conflict (shared upstream resource). True for + /// listener-based kinds where two consumers compete (Kafka group, PG slot, + /// SQS queue, etc.) and for Websocket where both subscribers fire on every + /// broadcast. False for kinds whose upstream identifier is implicitly + /// workspace-scoped at runtime (HTTP routes, Email local_part — clones for + /// the non-workspaced sub-case are filtered out, so any cloned row is + /// already collision-free vs. the parent). + const FORK_CONFLICT_ON_ENABLE: bool = true; fn get_deployed_object(path: String, parent_path: Option) -> DeployedObject; @@ -557,7 +566,7 @@ async fn update_trigger( Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, - Json(edit_trigger): Json>, + Json(mut edit_trigger): Json>, ) -> Result { let path = path.to_path(); check_scopes(&authed, || { @@ -574,6 +583,24 @@ async fn update_trigger( let mut tx = user_db.begin(&authed).await?; + // When the request omits `mode`/`enabled`, preserve the existing DB value + // instead of falling back to the BaseTriggerData default (Enabled). This + // keeps fork→parent git-sync round-trips from flipping the parent's + // operational state — see fork_trigger_ignore_keys in workspaces_export.rs. + if edit_trigger.base.is_mode_unspecified() { + let existing_mode: Option = sqlx::query_scalar(&format!( + "SELECT mode FROM {} WHERE workspace_id = $1 AND path = $2", + T::TABLE_NAME + )) + .bind(&workspace_id) + .bind(path) + .fetch_optional(&mut *tx) + .await?; + if let Some(m) = existing_mode { + edit_trigger.base.set_mode(m); + } + } + let new_path = edit_trigger.base.path.to_string(); let labels = edit_trigger.base.labels.clone(); let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( @@ -732,6 +759,52 @@ async fn exists_trigger( #[derive(serde::Deserialize)] struct SetTriggerModePayload { mode: TriggerMode, + /// When true, bypass the parent-state warning that would otherwise reject + /// enabling a trigger that's already enabled in the parent workspace. + /// The frontend sets this after the user confirms the duplicate-execution + /// dialog. See windmill-trigger/src/handler.rs::set_trigger_mode for the + /// full check. + #[serde(default)] + force: bool, +} + +/// Returns the parent workspace id when this workspace is a fork *and* the +/// parent has a row at the same trigger path. Used to gate enabling a trigger +/// in a fork behind an explicit `force=true` confirmation: the fork's row was +/// cloned from the parent, so its upstream identifier (Kafka group, PG slot, +/// SQS queue URL, etc.) is shared by construction. The risk is independent of +/// the parent's current `mode`: if the parent is enabled, the two listeners +/// compete; if it's disabled, the fork can destructively take over shared +/// state (e.g. advance the PG WAL, claim an MQTT client_id) before the parent +/// re-enables. Either way, the user should be asked to confirm. +async fn parent_has_trigger( + tx: &mut PgConnection, + table_name: &str, + workspace_id: &str, + path: &str, +) -> Result> { + let parent: Option = + sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1") + .bind(workspace_id) + .fetch_optional(&mut *tx) + .await? + .flatten(); + let Some(parent_id) = parent else { + return Ok(None); + }; + let exists: Option = sqlx::query_scalar(&format!( + "SELECT EXISTS(SELECT 1 FROM {} WHERE workspace_id = $1 AND path = $2)", + table_name + )) + .bind(&parent_id) + .bind(path) + .fetch_one(&mut *tx) + .await?; + Ok(if exists == Some(true) { + Some(parent_id) + } else { + None + }) } async fn set_trigger_mode( @@ -746,6 +819,28 @@ async fn set_trigger_mode( check_scopes(&authed, || format!("{}:write", T::scope_domain_name()))?; let mut tx = user_db.begin(&authed).await?; + + // Block transitioning a trigger in a fork to any mode that attaches a + // listener (Enabled or Suspended) when the parent has the same path, + // unless the caller passes force=true. Suspended still keeps the + // listener attached — it just stops auto-running queued jobs — so a + // suspended fork would still split Kafka events / share a PG slot + // with the parent. The cloned upstream identifier is shared by + // construction; the risk is independent of the parent's current mode. + // Skipped for kinds where the upstream identifier is already + // workspace-scoped at runtime (HTTP, Email). + if T::FORK_CONFLICT_ON_ENABLE && payload.mode != TriggerMode::Disabled && !payload.force { + if let Some(parent_id) = + parent_has_trigger(&mut *tx, T::TABLE_NAME, &workspace_id, path).await? + { + return Err(Error::BadRequest(format!( + "fork-conflict:{}:{}", + T::TRIGGER_TYPE, + parent_id + ))); + } + } + let updated = handler .set_trigger_mode(&authed, &mut *tx, &workspace_id, path, &payload.mode) .await?; diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 84f43547c9..8b42c5b1f9 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -128,6 +128,21 @@ impl BaseTriggerData { ) } + /// True when neither `mode` nor the legacy `enabled` field was provided in + /// the request. Used by the update path to distinguish "explicitly Enabled" + /// from "missing — preserve existing value", which matters for git-sync + /// round-trips through fork workspaces (see workspaces_export.rs). + pub fn is_mode_unspecified(&self) -> bool { + #[allow(deprecated)] + { + self.mode.is_none() && self.enabled.is_none() + } + } + + pub fn set_mode(&mut self, mode: TriggerMode) { + self.mode = Some(mode); + } + pub fn resolve_permissioned_as(&self, authed: &impl Authable) -> String { if let Some(ref permissioned_as) = self.permissioned_as { if self.preserve_permissioned_as.unwrap_or(false) diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 54b3bf6fa3..c92fc2a94e 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -592,13 +592,13 @@ async fn execute_windmill_tool( ) .await?; } - Ok(success) => { + Ok(outcome) => { handle_tool_execution_success( ctx, tool_call, tool_module, job_id, - success, + outcome.is_success(), inner_job_completed_rx, messages, final_events_str, diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 30acb913df..1f771f6879 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -478,7 +478,7 @@ pub async fn process_result( duration: Option, has_stream: bool, flow_runners: Option>, -) -> error::Result { +) -> error::Result { match result { Ok(result) => { send_job_completed( @@ -502,7 +502,7 @@ pub async fn process_result( ) .with_context(windmill_common::otel_oss::otel_ctx()) .await; - Ok(true) + Ok(crate::worker::JobOutcome::Completed) } Err(e) => { let error_value = match e { @@ -547,6 +547,23 @@ pub async fn process_result( }), }; + // Use the structured error message that was just extracted (the + // user-facing script error) rather than the generic Error string. + // Pull `.message` out of the JSON object if present, otherwise + // accept a bare string (e.g. agent-worker "See logs for more + // details"), and only fall back to "Job failed" when the value + // carries no readable description. + let description = serde_json::from_str::(error_value.get()) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(|m| m.as_str()) + .or_else(|| value.as_str()) + .map(|m| crate::worker::truncate_description(m)) + }) + .unwrap_or_else(|| "Job failed".to_string()); + send_job_completed( job_completed_tx, JobCompleted { @@ -568,7 +585,7 @@ pub async fn process_result( ) .with_context(windmill_common::otel_oss::otel_ctx()) .await; - Ok(false) + Ok(crate::worker::JobOutcome::Failed { description }) } } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index f062837d4b..15405800be 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1387,17 +1387,45 @@ pub fn create_span_with_name( /// payload on OTLP exporters. const STATUS_DESCRIPTION_MAX_LEN: usize = 512; +/// Outcome of running a queued job, carrying enough information to set the +/// OTLP `Status` on the outer `"job"` span without conflating "another worker +/// raced us" with "the user's script raised an exception". +#[derive(Debug)] +pub enum JobOutcome { + /// Job ran cleanly, was forwarded as a flow, was a no-op (test workspace), + /// or was suspended waiting for child jobs (WAC v2 / schedule zombie). + /// All of these leave the span `Status` `Unset`. + Completed, + /// Job was attempted but its execution returned an error; the failure has + /// been dispatched to the result processor. `description` holds the + /// truncated error string for the outer span's `Status.message`. + Failed { description: String }, + /// Another worker (or the same worker after a restart) already inserted a + /// row in `v2_job_completed`; this worker has nothing to do. + AlreadyCompleted, +} + +impl JobOutcome { + /// True when the job completed successfully on this worker. Used by + /// callers that previously matched on `Ok(true)`. + pub fn is_success(&self) -> bool { + matches!(self, Self::Completed) + } +} + /// Record `otel.status_code` / `otel.status_message` on the current span /// when a job fails. Called from inside the `.instrument(job_span)` future so /// that `Span::current()` resolves to the `"job"` span created by /// `create_span_with_name`. /// -/// On `Ok(true)` the fields are left unset, which `tracing-opentelemetry` -/// maps to `Status::Unset` (semantically equivalent to OK per OTel spec). -pub(crate) fn record_job_span_status(result: &windmill_common::error::Result) { +/// `Completed` leaves the fields unset (`Status::Unset`, equivalent to OK +/// per OTel spec). The other variants set `Status.code = ERROR` with a +/// description that reflects the actual cause. +pub(crate) fn record_job_span_status(result: &windmill_common::error::Result) { let description = match result { - Ok(true) => return, - Ok(false) => "job already completed by another worker".to_string(), + Ok(JobOutcome::Completed) => return, + Ok(JobOutcome::Failed { description }) => description.clone(), + Ok(JobOutcome::AlreadyCompleted) => "job already completed by another worker".to_string(), Err(err) => truncate_description(&err.to_string()), }; let span = tracing::Span::current(); @@ -2900,13 +2928,13 @@ pub async fn run_worker( .await; match job_result { - Ok(false) if is_init_script => { + Ok(ref outcome) if !outcome.is_success() && is_init_script => { tracing::error!("init script job failed, exiting"); update_worker_ping_for_failed_init_script(conn, &worker_name, job_id) .await; break; } - Ok(false) if is_periodic_bash_script => { + Ok(ref outcome) if !outcome.is_success() && is_periodic_bash_script => { tracing::error!( "periodic script job failed. Check logs for job ID {} for details.", job_id @@ -3388,7 +3416,7 @@ pub async fn handle_queued_job( precomputed_agent_info: Option, flow_runners: Option>, #[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter, -) -> windmill_common::error::Result { +) -> windmill_common::error::Result { if job.canceled_by.is_some() { return Err(Error::JsonErr(canceled_job_to_result(&job))); } @@ -3556,7 +3584,7 @@ pub async fn handle_queued_job( } } - return Ok(true); + return Ok(JobOutcome::Completed); } }; } @@ -3587,11 +3615,11 @@ pub async fn handle_queued_job( tracing::error!( "Schedule push zombie: {err}. Leaving flow job in queue for zombie detection to restart." ); - Ok(true) + Ok(JobOutcome::Completed) } other => { other?; - Ok(true) + Ok(JobOutcome::Completed) } } } else { @@ -3866,21 +3894,21 @@ pub async fn handle_queued_job( drop(job); //it's a test job, no need to update the db if cjob.workspace_id == "" { - return Ok(true); + return Ok(JobOutcome::Completed); } if result .as_ref() .is_err_and(|err| matches!(err, &Error::AlreadyCompleted(_))) { - return Ok(false); + return Ok(JobOutcome::AlreadyCompleted); } if result .as_ref() .is_err_and(|err| matches!(err, &Error::WacSuspended(_))) { // WAC v2 job suspended while waiting for child jobs — don't complete it - return Ok(true); + return Ok(JobOutcome::Completed); } process_result( cjob, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 1689b1bf30..05ad7c0e17 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.693.4"; +export const VERSION = "v1.694.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index 37f8ac343d..edbedc2612 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -153,7 +153,17 @@ export async function pushSchedule( ...preserveFields, }, }); - if (localSchedule.enabled != schedule.enabled) { + // Tarball export from a fork strips `enabled` from schedule YAMLs so + // the fork→parent git-sync round-trip can't flip the parent's state. + // Skip the secondary setScheduleEnabled call when the local YAML + // doesn't carry `enabled` — sending `{ enabled: undefined }` would + // serialize to `{}` and the backend (`SetEnabled.enabled` is required) + // would reject the request. Preserving the target's existing flag is + // exactly the round-trip-safe behavior. + if ( + localSchedule.enabled !== undefined && + localSchedule.enabled !== schedule.enabled + ) { log.info(colors.bold.yellow( `Schedule ${path} is ${localSchedule.enabled ? "enabled" : "disabled"} locally but not on remote, updating remote` )); @@ -187,20 +197,44 @@ export async function pushSchedule( } } -async function enable(opts: GlobalOptions, path: string) { +async function enable(opts: GlobalOptions & { force?: boolean }, path: string) { opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); await requireLogin(opts); - await wmill.setScheduleEnabled({ - workspace: workspace.workspaceId, - path, - requestBody: { enabled: true }, - }); + try { + await wmill.setScheduleEnabled({ + workspace: workspace.workspaceId, + path, + requestBody: { enabled: true, force: opts.force }, + }); + } catch (e) { + const conflict = parseForkConflict(e); + if (conflict) { + log.error( + `Cannot enable schedule '${path}': the parent workspace '${conflict.parentWorkspaceId}' has the same path configured. ` + + `Both crons would fire on every tick and the script would run twice per scheduled time.\n` + + `Re-run with --force to enable anyway.` + ); + process.exit(1); + } + throw e; + } log.info(colors.green(`Schedule ${path} enabled.`)); } +/** Parse a backend error body of the shape `fork-conflict::`. */ +function parseForkConflict( + e: unknown +): { kind: string; parentWorkspaceId: string } | undefined { + const body = (e as any)?.body; + const raw = typeof body === "string" ? body : (e as any)?.message ?? ""; + const m = String(raw).match(/fork-conflict:([^:]+):(.+)/); + if (!m) return undefined; + return { kind: m[1], parentWorkspaceId: m[2].trim() }; +} + async function disable(opts: GlobalOptions, path: string) { opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); @@ -260,6 +294,10 @@ const command = new Command() .arguments(" ") .action(push as any) .command("enable", "Enable a schedule") + .option( + "--force", + "Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)" + ) .arguments("") .action(enable as any) .command("disable", "Disable a schedule") diff --git a/cli/src/core/auth.ts b/cli/src/core/auth.ts index dcf8c40391..b3496d3cb8 100644 --- a/cli/src/core/auth.ts +++ b/cli/src/core/auth.ts @@ -84,6 +84,40 @@ export async function requireLogin( // rather than falling back to interactive login — they expect their explicit // credentials to work and should fix them if they don't. if (opts.token || opts.baseUrl) { + const isApiError = error && typeof error === "object" && + "name" in error && (error as { name: unknown }).name === "ApiError"; + if (isApiError) { + const status = (error as { status?: number }).status; + const body = (error as { body?: unknown }).body; + let bodyStr = typeof body === "object" && body !== null + ? JSON.stringify(body) + : String(body ?? "").trim(); + // Strip backend source-file refs like "(flows.rs:1400)" or + // "@scopes.rs:509" from the surfaced message — same pattern used in + // main.ts for ApiError display. + bodyStr = bodyStr.replace(/\s*[@(]\w+\.rs:\d+[:\d]*\)?/g, ""); + // The backend's `Error::PermissionDenied` formats with a + // "Permission denied: " prefix; the new CLI message also leads with + // that phrase, so strip it from the body to avoid duplication. + bodyStr = bodyStr.replace(/^(Permission denied|Not authorized): /, ""); + if (status === 403) { + // 403 means the token authenticated but lacks scope — re-issuing + // won't help. Keep this distinct from the 401 message so the user + // doesn't waste time reproducing the token. + log.info(colors.red( + `Permission denied: the token is valid but lacks the required scope.${bodyStr ? `\n${bodyStr}` : ""}` + )); + } else if (status === 401) { + log.info(colors.red( + `Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.${bodyStr ? `\n${bodyStr}` : ""}` + )); + } else { + log.info(colors.red( + `Request failed (${status ?? "unknown"}): ${bodyStr}` + )); + } + return process.exit(1); + } log.info(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.")); return process.exit(1); } diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 2d2c0a0ced..0f2c101b58 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6756,6 +6756,7 @@ schedule related commands - \`schedule new \` - create a new schedule locally - \`schedule push \` - push a local schedule spec. This overrides any remote versions. - \`schedule enable \` - Enable a schedule + - \`--force\` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire) - \`schedule disable \` - Disable a schedule - \`schedule set-permissioned-as \` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group) diff --git a/cli/src/main.ts b/cli/src/main.ts index cfb373d0c7..e0e062d87f 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -78,7 +78,7 @@ export { token, }; -export const VERSION = "1.693.4"; +export const VERSION = "1.694.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/docs/fork-triggers.md b/docs/fork-triggers.md new file mode 100644 index 0000000000..d3cd3e307d --- /dev/null +++ b/docs/fork-triggers.md @@ -0,0 +1,173 @@ +# Triggers and schedules in workspace forks + +A workspace fork is a developer-controlled copy of a parent workspace, used to +test changes before merging back via git sync. Triggers and schedules in a +fork need special handling for two reasons: + +1. **Listener take-over**: most trigger kinds (Kafka, Postgres, MQTT, NATS, + SQS, GCP, Azure) attach to a stateful upstream resource. Two listeners + sharing the same identifier compete for events. +2. **Merge round-trip**: every change made in a fork can flow back to the + parent through git sync. If the fork sets a trigger to `disabled`, that + value would otherwise overwrite the parent's `enabled` state on merge. + +## Cloning model — always cloned, always disabled + +Fork creation always runs `clone_triggers_and_schedules`. Every row in each +`*_trigger` table and in `schedule` is copied from the parent into the fork +with two invariants: + +- **Always disabled.** The clone forces `mode='disabled'::TRIGGER_MODE` on + triggers and `enabled=false` on schedules, regardless of the parent's + state. Disabled rows have **no side effects** — no listener attaches to + the upstream, no cron fires — so this clone is safe by construction. + The user re-enables manually in the fork. +- **Listener identifiers copied verbatim.** Stored values for `group_id`, + `replication_slot_name`, `subscription_name`, `client_id`, + `consumer_name`, etc. are copied 1:1. Until the runtime-suffix work + ships (see below), enabling a cloned listener in the fork would compete + with the parent — the conflict warning below catches that case. + +`native_trigger` (Nextcloud, Google Drive, GitHub) is intentionally **not +cloned**. Those triggers manage external webhook state we don't want +duplicated. + +**Non-workspaced HTTP triggers are also skipped.** A row with +`workspaced_route=false` (and where neither `CLOUD_HOSTED` nor the +`HTTP_ROUTE_WORKSPACED_ROUTE` instance setting is on) has a runtime URL +without any workspace prefix. A clone would collide with the parent's row at +the matchit router level, where duplicate inserts are silently dropped — one +trigger would invisibly hijack the other. There is no namespacing escape +hatch for these (the whole point of `workspaced_route=false` is to skip the +prefix), so the clone filter excludes them. The fork user can re-create one +manually if they need it. When `CLOUD_HOSTED` or `HTTP_ROUTE_WORKSPACED_ROUTE` +is on, every route is workspace-prefixed at runtime regardless of the column, +and the clone copies all rows. + +**Non-workspaced email triggers are skipped on the same grounds.** A row +with `workspaced_local_part=false` exposes a bare `local_part@domain` +address shared instance-wide; a clone would share the address with the +parent and incoming mail would be delivered arbitrarily. The clone filter +copies email triggers only when `workspaced_local_part IS TRUE` (or +`CLOUD_HOSTED`, since cloud scopes email lookup by `workspace_id` natively). + +## Merge-direction filter (always on) + +Whenever the source workspace has `parent_workspace_id IS NOT NULL` (i.e. +it's a fork), the tarball export at `/api/w/{workspace}/workspaces/tarball` +strips fork-local fields: + +- `mode` from every `*_trigger` row +- `enabled` from every `schedule` row + +The fork-detection key is the column, not the `wm-fork-*` naming convention, +so it stays consistent with the conflict-warning gates in `set_trigger_mode` +and `set_schedule_enabled` and survives any future ID rename. + +The trigger update handler complements this: when an incoming `update_trigger` +request omits both `mode` and `enabled`, the existing DB value is preserved +instead of falling back to the BaseTriggerData default of `Enabled`. This +means the fork→parent merge cannot flip the parent's operational state, even +if the fork has an explicit (locally-disabled) state for that path. + +The schedule `EditSchedule` payload already lacks an `enabled` field, so its +update path is naturally safe. + +## Conflict warning on enable + +The `set_*_trigger_mode` endpoint fires the warning whenever a fork transitions +to a mode that *attaches a listener* — `Enabled` or `Suspended`. Suspended is +not "off": the listener still attaches and consumes events; only the auto-run +of queued jobs is paused. Two suspended forks would still split Kafka events +or share a Postgres slot with the parent. `Disabled` is the only mode that +fully detaches. + +The check fires whenever the parent workspace has a row at the same trigger +path — **regardless of the parent's current `mode`/`enabled`**. If so, the +endpoint rejects the request with an error string of the shape: + +``` +fork-conflict:: +``` + +The frontend's `withForkConflictRetry` helper detects this prefix, asks the +user to confirm via a dialog (a `ConfirmationModal` mounted at the logged +layout root, driven by the `forkConflictModal` store), and re-issues the +call with `force: true` if the user agrees. The CLI sees the raw error. + +The check fires whenever the parent has the row because the fork's row was +*cloned* from the parent — the upstream identifier (Kafka group, PG slot, +SQS queue URL, GCP/Azure subscription, …) is shared by construction. That +sharing is a risk independent of the parent's current state: + +- Both enabled → the listeners compete (split events) or fire twice. +- Parent disabled → the fork can destructively claim shared state (PG WAL + advance, Azure secret_hash reuse, MQTT client_id race) before the parent + re-enables. + +The check is opt-out per kind via `TriggerCrud::FORK_CONFLICT_ON_ENABLE` +(default `true`). It is **skipped** for kinds whose upstream identifier is +already workspace-scoped at runtime — fork and parent there can never share +a real upstream: + +- **HTTP** — routes are `/r//...`; cloned rows always have + `workspaced_route=true` (non-workspaced are filtered out at clone time). +- **Email** — addresses are workspace-prefixed; cloned rows always have + `workspaced_local_part=true`. + +The check **fires** for every other kind. The frontend modal copy splits the +conflict into three families so the user can act on the right risk: + +- **Split events** (Kafka, NATS, MQTT, SQS, GCP, Azure) — events split + between the two listeners; each side receives a fraction of its traffic. +- **Duplicate firing** (Websocket, Schedule) — every event fires the script + twice (once in fork, once in parent). +- **Slot takeover** (Postgres) — the replication slot is exclusive *and* + destructive: enabling either errors with "slot already active" (parent + enabled) or hijacks the WAL position (parent disabled). + +This warning is the *durable* solution for trigger kinds where the conflict +cannot be eliminated by namespacing alone: + +- **SQS** — the queue *is* the event source; two consumers will compete for + messages no matter what. +- **GCP-Existing subscription** — same as SQS. +- **Schedule** — same wall-clock firing. + +For the kinds that *can* be auto-namespaced (see below), the warning is the +short-term placeholder until that work lands. + +## Merge UI behavior + +The merge UI (`CompareWorkspaces.svelte`) lists triggers and schedules +side-by-side from both workspaces, computes a per-row change check that +ignores runtime fields (`mode`, `enabled`, `server_id`, `last_server_ping`, +`edited_at`/`edited_by`, `extra_perms`, `permissioned_as`), and only shows +rows that differ in actual config. A fresh clone (only `mode` differs) is +filtered out. + +Triggers and schedules are **never auto-selected** in the default deploy / +update selection — only diff items (scripts/flows/apps/etc.) are. The user +opts in by clicking individual trigger rows. This keeps a routine +`Deploy to parent` flow from accidentally pushing trigger config the fork +hasn't intentionally changed. + +## Future work — runtime listener suffix + +A follow-up PR will append a fork-specific suffix to the upstream identifier +at runtime for the kinds that support it: + +| Kind | Identifier | Notes | +|---|---|---| +| Kafka | `group_id` | Two consumer groups never share messages. | +| MQTT | `client_id` | Brokers reject duplicate client_ids; suffix avoids that. | +| NATS | durable consumer name | Fork consumes independently. | +| Postgres | `replication_slot_name` + `publication_name` | Fork auto-creates its own publication on enable, drops on disable / fork delete. | +| Azure Event Grid | `subscription_name` | `manage_azure_subscription` creates the suffixed sub in Azure. | +| GCP Pub/Sub (CreateNew) | `subscription_id` | `manage_google_subscription` creates the suffixed sub. | + +The suffix is applied at runtime by the listener — the *stored* identifier +column never carries the suffix, so nothing extra needs to be filtered on +export. The follow-up also adds cleanup-on-fork-delete hooks for the +upstream resources (Azure / GCP / Postgres publication) so deleted forks +don't leak external state. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e346c9c811..2ae960709e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.693.4", + "version": "1.694.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.693.4", + "version": "1.694.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index fae6f4cce1..b578298993 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.693.4", + "version": "1.694.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index d9e94264a9..2d0ae4b82c 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -10,12 +10,10 @@ CircleCheck, CircleX, DiffIcon, - Eye, FileJson, FlaskConical, GitFork, Loader2, - Trash2, UserPlus } from 'lucide-svelte' import type { CiTestResult } from '$lib/gen' @@ -42,20 +40,11 @@ type WorkspaceItemDiff } from '$lib/gen' import Button from './common/button/Button.svelte' - import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' import DiffDrawer from './DiffDrawer.svelte' + import DiffEditor from './DiffEditor.svelte' + import Drawer from './common/drawer/Drawer.svelte' + import DrawerContent from './common/drawer/DrawerContent.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' - import ScheduleEditor from './triggers/schedules/ScheduleEditor.svelte' - import RouteEditor from './triggers/http/RouteEditor.svelte' - import WebsocketTriggerEditor from './triggers/websocket/WebsocketTriggerEditor.svelte' - import KafkaTriggerEditor from './triggers/kafka/KafkaTriggerEditor.svelte' - import PostgresTriggerEditor from './triggers/postgres/PostgresTriggerEditor.svelte' - import NatsTriggerEditor from './triggers/nats/NatsTriggerEditor.svelte' - import MqttTriggerEditor from './triggers/mqtt/MqttTriggerEditor.svelte' - import SqsTriggerEditor from './triggers/sqs/SqsTriggerEditor.svelte' - import GcpTriggerEditor from './triggers/gcp/GcpTriggerEditor.svelte' - import AzureTriggerEditor from './triggers/azure/AzureTriggerEditor.svelte' - import EmailTriggerEditor from './triggers/email/EmailTriggerEditor.svelte' import { userWorkspaces, workspaceStore } from '$lib/stores' import type { Kind } from '$lib/utils_deployable' @@ -74,6 +63,7 @@ } from './OnBehalfOfSelector.svelte' import { sendUserToast } from '$lib/toast' import { deepEqual } from 'fast-equals' + import { orderedJsonStringify, orderedYamlStringify } from '$lib/utils' import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte' import { userStore } from '$lib/stores' @@ -551,6 +541,7 @@ })) const triggerItems = forkTriggers .filter((t) => { + if (!isTriggerRelevantForDirection(t, mergeIntoParent)) return false const key = getTriggerKey(t) return deploymentStatus[key]?.status !== 'deployed' }) @@ -558,6 +549,7 @@ key: getTriggerKey(trigger), path: trigger.path, kind: 'trigger' as Kind, + triggerKind: trigger.triggerKind, diff: undefined as WorkspaceItemDiff | undefined, trigger })) @@ -571,8 +563,21 @@ triggerKind: TriggerKind scriptPath: string isFlow: boolean - enabled?: boolean extraLabel?: string + /** Raw row as returned by the listX endpoint, used to detect changes + * between the fork and parent workspaces. */ + raw?: any + /** True only when both workspaces have the trigger and the relevant + * fields differ. False when the trigger only exists on one side. */ + hasChanges?: boolean + /** Source value for this row in a diff view (raw object from the + * workspace whose state we'd push). */ + sourceRaw?: any + /** Target value (the row that would be overwritten). */ + targetRaw?: any + /** "new" when only in source, "modified" when both differ, + * "deleted-in-source" when only in target. */ + changeKind?: 'new' | 'modified' | 'deleted-in-source' } let ciTestResults = $state>({}) @@ -627,222 +632,222 @@ }) let forkTriggers = $state([]) - let triggerToDelete = $state(undefined) let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined) let hasOpenDeploymentRequest = $state(false) - // Trigger detail drawer refs — one per trigger kind. Each is lazy-mounted - // on first openEdit() call, so having them all sit here is cheap. - let scheduleEditor: ScheduleEditor | undefined = $state() - let routeEditor: RouteEditor | undefined = $state() - let websocketEditor: WebsocketTriggerEditor | undefined = $state() - let kafkaEditor: KafkaTriggerEditor | undefined = $state() - let postgresEditor: PostgresTriggerEditor | undefined = $state() - let natsEditor: NatsTriggerEditor | undefined = $state() - let mqttEditor: MqttTriggerEditor | undefined = $state() - let sqsEditor: SqsTriggerEditor | undefined = $state() - let gcpEditor: GcpTriggerEditor | undefined = $state() - let azureEditor: AzureTriggerEditor | undefined = $state() - let emailEditor: EmailTriggerEditor | undefined = $state() - - function openTriggerDetails(trigger: ForkTrigger) { - const isFlow = trigger.isFlow - switch (trigger.triggerKind) { - case 'schedules': - scheduleEditor?.openEdit(trigger.path, isFlow) - break - case 'routes': - routeEditor?.openEdit(trigger.path, isFlow) - break - case 'websockets': - websocketEditor?.openEdit(trigger.path, isFlow) - break - case 'kafka': - kafkaEditor?.openEdit(trigger.path, isFlow) - break - case 'postgres': - postgresEditor?.openEdit(trigger.path, isFlow) - break - case 'nats': - natsEditor?.openEdit(trigger.path, isFlow) - break - case 'mqtt': - mqttEditor?.openEdit(trigger.path, isFlow) - break - case 'sqs': - sqsEditor?.openEdit(trigger.path, isFlow) - break - case 'gcp': - gcpEditor?.openEdit(trigger.path, isFlow) - break - case 'azure': - azureEditor?.openEdit(trigger.path, isFlow) - break - case 'emails': - emailEditor?.openEdit(trigger.path, isFlow) - break - } - } - - /** Deployable trigger kinds and their list+delete services */ + /** Deployable trigger kinds and their list services */ const triggerServices = { schedules: { list: (ws: string) => ScheduleService.listSchedules({ workspace: ws }), - delete: (ws: string, path: string) => ScheduleService.deleteSchedule({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'schedules', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.enabled, - extraLabel: item.schedule + extraLabel: item.schedule, + raw: item }) }, routes: { list: (ws: string) => HttpTriggerService.listHttpTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - HttpTriggerService.deleteHttpTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'routes', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', extraLabel: `${(item.http_method ?? 'get').toUpperCase()} ${item.route_path ?? ''}` }) }, websockets: { list: (ws: string) => WebsocketTriggerService.listWebsocketTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - WebsocketTriggerService.deleteWebsocketTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'websockets', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', - extraLabel: item.url + extraLabel: item.url, + raw: item }) }, kafka: { list: (ws: string) => KafkaTriggerService.listKafkaTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - KafkaTriggerService.deleteKafkaTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'kafka', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', - extraLabel: item.topics?.join(', ') + extraLabel: item.topics?.join(', '), + raw: item }) }, postgres: { list: (ws: string) => PostgresTriggerService.listPostgresTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - PostgresTriggerService.deletePostgresTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'postgres', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled' + raw: item }) }, nats: { list: (ws: string) => NatsTriggerService.listNatsTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - NatsTriggerService.deleteNatsTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'nats', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', - extraLabel: item.subjects?.join(', ') + extraLabel: item.subjects?.join(', '), + raw: item }) }, mqtt: { list: (ws: string) => MqttTriggerService.listMqttTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - MqttTriggerService.deleteMqttTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'mqtt', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled' + raw: item }) }, sqs: { list: (ws: string) => SqsTriggerService.listSqsTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - SqsTriggerService.deleteSqsTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'sqs', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', - extraLabel: item.queue_url + extraLabel: item.queue_url, + raw: item }) }, gcp: { list: (ws: string) => GcpTriggerService.listGcpTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - GcpTriggerService.deleteGcpTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'gcp', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', - extraLabel: item.topic_id + extraLabel: item.topic_id, + raw: item }) }, azure: { list: (ws: string) => AzureTriggerService.listAzureTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - AzureTriggerService.deleteAzureTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'azure', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', - extraLabel: item.topic_name ?? item.scope_resource_id + extraLabel: item.topic_name ?? item.scope_resource_id, + raw: item }) }, emails: { list: (ws: string) => EmailTriggerService.listEmailTriggers({ workspace: ws }), - delete: (ws: string, path: string) => - EmailTriggerService.deleteEmailTrigger({ workspace: ws, path }), normalize: (item: any): ForkTrigger => ({ path: item.path, triggerKind: 'emails', scriptPath: item.script_path, isFlow: item.is_flow, - enabled: item.mode === 'enabled', extraLabel: getEmailAddress( item.local_part, item.workspaced_local_part, currentWorkspaceId, emailDomain ?? '' - ) + ), + raw: item }) } } as const let emailDomain = $state(undefined) + /** + * Fields that should not count as a "change" between the parent and the + * fork. Mode/enabled are forced to 'disabled'/false on clone and stripped + * by the fork-export filter; the rest are runtime state or per-row + * metadata that always diverges. Comparing without these matches the + * semantics of "is this trigger configured the same way?" rather than + * "are these two rows byte-identical?". + */ + const TRIGGER_COMPARE_IGNORE = new Set([ + 'workspace_id', + 'mode', + 'enabled', + 'edited_at', + 'edited_by', + 'last_server_ping', + 'server_id', + 'error', + 'extra_perms', + 'permissioned_as' + ]) + + function stripIgnoredFields(row: any): any { + if (!row || typeof row !== 'object') return row + const out: Record = {} + for (const [k, v] of Object.entries(row)) { + if (!TRIGGER_COMPARE_IGNORE.has(k)) out[k] = v + } + return out + } + + function rowsHaveSameConfig(a: any, b: any): boolean { + return ( + orderedJsonStringify(stripIgnoredFields(a)) === orderedJsonStringify(stripIgnoredFields(b)) + ) + } + async function fetchAllTriggers() { try { emailDomain = await getEmailDomain() - const entries = Object.values(triggerServices) + const entries = Object.entries(triggerServices) as Array< + [TriggerKind, (typeof triggerServices)[keyof typeof triggerServices]] + > + // Fetch fork + parent in parallel for each kind. Either side may + // fail (e.g. permission denied on parent) — fall back to empty. const results = await Promise.allSettled( - entries.map(async (svc) => { - const items = await svc.list(currentWorkspaceId) - return items.map(svc.normalize) + entries.map(async ([kind, svc]) => { + const [forkItems, parentItems] = await Promise.all([ + svc.list(currentWorkspaceId).catch(() => [] as any[]), + svc.list(parentWorkspaceId).catch(() => [] as any[]) + ]) + const byPath = new Map() + for (const item of forkItems) { + byPath.set(item.path, { fork: item }) + } + for (const item of parentItems) { + const entry = byPath.get(item.path) ?? {} + entry.parent = item + byPath.set(item.path, entry) + } + const merged: ForkTrigger[] = [] + for (const [path, entry] of byPath) { + const sourceItem = entry.fork ?? entry.parent + const normalized = svc.normalize(sourceItem) + let changeKind: ForkTrigger['changeKind'] + let hasChanges = false + if (entry.fork && !entry.parent) { + changeKind = 'new' + } else if (!entry.fork && entry.parent) { + changeKind = 'deleted-in-source' + } else if (entry.fork && entry.parent) { + hasChanges = !rowsHaveSameConfig(entry.fork, entry.parent) + if (hasChanges) changeKind = 'modified' + } + merged.push({ + ...normalized, + raw: sourceItem, + hasChanges, + changeKind, + sourceRaw: entry.fork, + targetRaw: entry.parent, + path + }) + } + return merged }) ) forkTriggers = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) @@ -852,28 +857,21 @@ } } - function deleteTrigger(trigger: ForkTrigger) { - triggerToDelete = trigger - } - - async function confirmDeleteTrigger() { - const trigger = triggerToDelete - if (!trigger) return - triggerToDelete = undefined - const triggerType = triggerKindToTriggerType(trigger.triggerKind) - const displayName = triggerType ? triggerDisplayNamesMap[triggerType] : trigger.triggerKind - try { - const svc = triggerServices[trigger.triggerKind as keyof typeof triggerServices] - if (!svc) { - throw new Error(`No service for trigger kind: ${trigger.triggerKind}`) - } - await svc.delete(currentWorkspaceId, trigger.path) - forkTriggers = forkTriggers.filter( - (t) => !(t.path === trigger.path && t.triggerKind === trigger.triggerKind) - ) - sendUserToast(`Deleted ${displayName} trigger '${trigger.path}'`) - } catch (e: any) { - sendUserToast(`Failed to delete trigger '${trigger.path}': ${e.body || e.message}`, true) + /** + * Triggers worth showing in the merge UI given the current direction. + * - "Deploy to parent": rows that exist in fork and either don't exist in + * parent or have config differences. + * - "Update current" (pull from parent): mirror. + * Triggers that exist on both sides with identical config are filtered + * out — they would generate a no-op deploy and only add noise. + */ + function isTriggerRelevantForDirection(t: ForkTrigger, deployingToParent: boolean): boolean { + const existsInFork = !!t.sourceRaw + const existsInParent = !!t.targetRaw + if (deployingToParent) { + return existsInFork && (!existsInParent || !!t.hasChanges) + } else { + return existsInParent && (!existsInFork || !!t.hasChanges) } } @@ -882,6 +880,38 @@ return triggerType ? triggerDisplayNamesMap[triggerType] : triggerKind } + let triggerDiffOpen = $state(false) + let triggerDiffPayload = $state< + | { + kindLabel: string + path: string + originalLabel: string + modifiedLabel: string + original: string + modified: string + } + | undefined + >(undefined) + + function openTriggerDiff(t: ForkTrigger) { + // `sourceRaw` is the fork row, `targetRaw` is the parent row regardless + // of direction (set in fetchAllTriggers). The diff reads from the + // destination (left) to the source (right), matching the deploy arrow. + const sourceWorkspace = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId + const targetWorkspace = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId + const fromRow = mergeIntoParent ? t.sourceRaw : t.targetRaw + const toRow = mergeIntoParent ? t.targetRaw : t.sourceRaw + triggerDiffPayload = { + kindLabel: getTriggerDisplayName(t.triggerKind), + path: t.path, + originalLabel: `${targetWorkspace} (target)`, + modifiedLabel: `${sourceWorkspace} (source)`, + original: orderedYamlStringify(stripIgnoredFields(toRow ?? {})), + modified: orderedYamlStringify(stripIgnoredFields(fromRow ?? {})) + } + triggerDiffOpen = true + } + // Fetch triggers when workspace is available $effect(() => { if (currentWorkspaceId) { @@ -1089,7 +1119,10 @@ {#if item.trigger} {@const t = item.trigger as ForkTrigger} {getTriggerDisplayName(t.triggerKind)} - + {#if t.extraLabel} + {t.extraLabel} + {/if} + {t.scriptPath} {:else} {@const diff = item.diff as WorkspaceItemDiff} @@ -1120,30 +1153,38 @@ {#if item.trigger} {@const t = item.trigger as ForkTrigger} {@const key = item.key} - Fork-only + {#if t.changeKind === 'new'} + New + {/if} {#if t.isFlow} flow {/if} - {#if t.extraLabel} - ({t.extraLabel}) - {/if} - {#if t.enabled != null} - - {t.enabled ? 'Enabled' : 'Disabled'} - - {/if} {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'} - - + {#if mergeIntoParent} + + + 1 ahead + + {:else} + + + 1 behind + + {/if} + {#if t.changeKind === 'modified'} +
+ +
+ {/if} {/if} {:else} {@const diff = item.diff as WorkspaceItemDiff} @@ -1326,30 +1367,30 @@ - - - - - - - - - - - - - (triggerToDelete = undefined)} - > - {#if triggerToDelete} - Are you sure you want to delete the {getTriggerDisplayName(triggerToDelete.triggerKind)} trigger - '{triggerToDelete.path}'? - {/if} - + + (triggerDiffOpen = false)} + > + {#if triggerDiffPayload} +
+
+ +
+
+ {/if} +
+
{:else}
No comparison data available
diff --git a/frontend/src/lib/components/FlowRestartButton.svelte b/frontend/src/lib/components/FlowRestartButton.svelte index 7dc8f7b204..d470c51b54 100644 --- a/frontend/src/lib/components/FlowRestartButton.svelte +++ b/frontend/src/lib/components/FlowRestartButton.svelte @@ -40,9 +40,18 @@ * Map from ForLoop step id to the number of iterations that ran in the * original execution (i.e. `flow_jobs.length`). When provided for a step, * the popup renders a `` shows. Step ids are globally unique within a - // single flow value, so the collision only happens across subflow boundaries. + // Iteration counts indexed by the graph module-state key (i.e. the prefixed + // `subflow:...:` for in-subflow loops, or the bare `step_id` for + // top-level loops). Used by the popup for the SELECTED step's iteration + // picker — that step is always at the unprefixed top level (the run page's + // graph-state key matches the bare step_id), so the lookup is unambiguous. + // For nested-path iteration fields, see `nestedPathIterationCounts` instead, + // which is keyed by the popup's field-key ('top' / 'inner-N') and pulled + // from the path-aware graph key — avoiding collisions when the same step + // id appears at both the parent flow and inside a subflow. const iterationCounts = $derived.by((): Record => { const out: Record = {} for (const [id, state] of Object.entries(opts.graphModuleStates())) { const n = state.flow_jobs?.length if (typeof n !== 'number' || n <= 0) continue out[id] = n - const lastColon = id.lastIndexOf(':') - if (lastColon >= 0) { - out[id.slice(lastColon + 1)] = n - } } return out }) @@ -318,6 +341,9 @@ export function useNestedRestartState(opts: { }, get iterationCounts() { return iterationCounts + }, + get nestedPathIterationCounts() { + return nestedPathIterationCounts } } } diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 2da17cc300..e19a1cc34d 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -132,12 +132,25 @@ export const codeCompletionSessionEnabled = writable( export const usedTriggerKinds = writable([]) export let globalDbManagerDrawer: StateStore = { val: undefined } -export let globalForkModal: StateStore = createState({ val: undefined }) +export let globalForkModal: StateStore = createState({ + val: undefined +}) export type GlobalForkModalState = { opened: true } +export type ForkConflictModalState = { + kind: string + kindLabel: string + parentWorkspaceId: string + resolve: (proceed: boolean) => void +} + +export let forkConflictModal: StateStore = createState({ + val: undefined +}) + type SQLBaseSchema = { [schemaKey: string]: { [tableKey: string]: { diff --git a/frontend/src/lib/utils/forkConflict.ts b/frontend/src/lib/utils/forkConflict.ts new file mode 100644 index 0000000000..d903790b44 --- /dev/null +++ b/frontend/src/lib/utils/forkConflict.ts @@ -0,0 +1,75 @@ +import { forkConflictModal } from '$lib/stores' + +/** + * The backend rejects "enable" requests on triggers/schedules in a fork when + * the parent workspace has the same path enabled. The error body is shaped as + * `fork-conflict::` + * so the UI can show a tailored confirm-to-proceed dialog and re-issue the + * call with `force: true` if the user agrees. + */ +export interface ForkConflict { + kind: string + parentWorkspaceId: string +} + +export function detectForkConflict(e: unknown): ForkConflict | null { + const body = (e as any)?.body + const raw = + typeof body === 'string' + ? body + : ((body as any)?.error?.message ?? (body as any)?.message ?? (e as any)?.message ?? '') + const m = String(raw).match(/fork-conflict:([^:]+):(.+)/) + if (!m) return null + return { kind: m[1], parentWorkspaceId: m[2].trim() } +} + +/** + * Opens the global ForkConflictModal and awaits the user's choice. Resolves + * to true when the user clicks "Enable anyway", false when they cancel or + * dismiss. If a previous modal is still pending (e.g. user clicked toggles + * on two rows in quick succession), resolve the older promise to false so + * the prior caller doesn't hang. + */ +function askForkConflictConfirm(kind: string, kindLabel: string, parentWorkspaceId: string) { + return new Promise((resolve) => { + const previous = forkConflictModal.val + previous?.resolve(false) + forkConflictModal.val = { kind, kindLabel, parentWorkspaceId, resolve } + }) +} + +/** + * Catches a fork-conflict error from `fn(false)`, shows the confirmation + * dialog, and retries with `fn(true)` when the user accepts. Re-throws every + * other error. + * + * Returns `true` when the call committed (no conflict, or user confirmed and + * retry succeeded) and `false` when the user dismissed the modal. Callers + * should bail on `false` to skip success toasts and revert any optimistic UI + * state. + * + * `kindLabel` is shown to the user — pass a friendly name like "kafka trigger" + * or "schedule" so the dialog reads naturally. + */ +export async function withForkConflictRetry( + fn: (force: boolean) => Promise, + kindLabel: string +): Promise { + try { + await fn(false) + return true + } catch (e) { + const conflict = detectForkConflict(e) + if (!conflict) throw e + const proceed = await askForkConflictConfirm( + conflict.kind, + kindLabel, + conflict.parentWorkspaceId + ) + // User explicitly dismissed the modal — treat as a silent no-op so the + // caller's catch block doesn't pop a redundant error toast. + if (!proceed) return false + await fn(true) + return true + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 8b7f86bd05..abab3fe20d 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -15,6 +15,7 @@ import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' + import ForkConflictModal from '$lib/components/ForkConflictModal.svelte' import { enterpriseLicense, isPremiumStore, @@ -837,6 +838,8 @@ {/if} + + { + async function onToggleMode(path: string, mode: TriggerMode): Promise { + let committed = false try { - await AzureTriggerService.setAzureTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode } - }) + const ok = await withForkConflictRetry( + (force) => + AzureTriggerService.setAzureTriggerMode({ + path, + workspace: $workspaceStore!, + requestBody: { mode, force } + }), + 'Azure trigger' + ) + committed = ok + if (ok) loadTriggers() } catch (err) { sendUserToast( `Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} Azure Event Grid trigger: ${err.body}`, true ) - } finally { loadTriggers() } + return committed } run(() => { diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte index 0e88af7554..eae45ef84f 100644 --- a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte @@ -227,6 +227,8 @@ async function onToggleMode(path: string, mode: TriggerMode): Promise { try { + // Email addresses are always workspace-prefixed (clone filter + // excludes workspaced_local_part=false) — no fork-conflict warning. await EmailTriggerService.setEmailTriggerMode({ path, workspace: $workspaceStore!, diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 0f292d7148..bb65a27065 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -17,6 +17,7 @@ storeLocalSetting, removeTriggerKindIfUnused } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' @@ -111,21 +112,28 @@ clearInterval(interval) }) - async function onToggleMode(path: string, mode: TriggerMode): Promise { + async function onToggleMode(path: string, mode: TriggerMode): Promise { + let committed = false try { - await GcpTriggerService.setGcpTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode } - }) + const ok = await withForkConflictRetry( + (force) => + GcpTriggerService.setGcpTriggerMode({ + path, + workspace: $workspaceStore!, + requestBody: { mode, force } + }), + 'GCP Pub/Sub trigger' + ) + committed = ok + if (ok) loadTriggers() } catch (err) { sendUserToast( `Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} GCP Pub/Sub trigger: ${err.body}`, true ) - } finally { loadTriggers() } + return committed } run(() => { diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index 93d85ebcce..16efb4ec65 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -16,6 +16,7 @@ storeLocalSetting, removeTriggerKindIfUnused } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' @@ -104,13 +105,20 @@ clearInterval(interval) }) - async function onToggleMode(path: string, mode: TriggerMode): Promise { + async function onToggleMode(path: string, mode: TriggerMode): Promise { + let committed = false try { - await KafkaTriggerService.setKafkaTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode } - }) + const ok = await withForkConflictRetry( + (force) => + KafkaTriggerService.setKafkaTriggerMode({ + path, + workspace: $workspaceStore!, + requestBody: { mode, force } + }), + 'Kafka trigger' + ) + committed = ok + if (ok) loadTriggers() } catch (err) { sendUserToast( `Cannot ` + @@ -118,9 +126,9 @@ ` Kafka trigger: ${err.body}`, true ) - } finally { loadTriggers() } + return committed } run(() => { diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte index 128fda57bb..6616c546c6 100644 --- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte @@ -17,6 +17,7 @@ storeLocalSetting, removeTriggerKindIfUnused } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' @@ -99,22 +100,31 @@ clearInterval(interval) }) - async function onToggleMode(path: string, mode: TriggerMode): Promise { + async function onToggleMode(path: string, mode: TriggerMode): Promise { + let committed = false try { - await MqttTriggerService.setMqttTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode } - }) - sendUserToast(`${capitalize(mode)} MQTT trigger ${path}`) + const ok = await withForkConflictRetry( + (force) => + MqttTriggerService.setMqttTriggerMode({ + path, + workspace: $workspaceStore!, + requestBody: { mode, force } + }), + 'MQTT trigger' + ) + if (ok) { + sendUserToast(`${capitalize(mode)} MQTT trigger ${path}`) + loadTriggers() + } + committed = ok } catch (err) { sendUserToast( `Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} mqtt trigger: ${err.body}`, true ) - } finally { loadTriggers() } + return committed } run(() => { diff --git a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte index b91a8ada1d..c797cb545e 100644 --- a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte @@ -16,6 +16,7 @@ storeLocalSetting, removeTriggerKindIfUnused } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' @@ -103,13 +104,20 @@ clearInterval(interval) }) - async function onToggleMode(path: string, mode: TriggerMode): Promise { + async function onToggleMode(path: string, mode: TriggerMode): Promise { + let committed = false try { - await NatsTriggerService.setNatsTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode } - }) + const ok = await withForkConflictRetry( + (force) => + NatsTriggerService.setNatsTriggerMode({ + path, + workspace: $workspaceStore!, + requestBody: { mode, force } + }), + 'NATS trigger' + ) + committed = ok + if (ok) loadTriggers() } catch (err) { sendUserToast( `Cannot ` + @@ -117,9 +125,9 @@ ` NATS trigger: ${err.body}`, true ) - } finally { loadTriggers() } + return committed } run(() => { diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index fbc5311e32..31e3c57b6b 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -17,6 +17,7 @@ removeTriggerKindIfUnused, capitalize } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' @@ -110,19 +111,28 @@ clearInterval(interval) }) - async function onToggleMode(path: string, mode: TriggerMode): Promise { + async function onToggleMode(path: string, mode: TriggerMode): Promise { + let committed = false try { - await PostgresTriggerService.setPostgresTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode } - }) - sendUserToast(`${capitalize(mode)} postgres trigger ${path}`) + const ok = await withForkConflictRetry( + (force) => + PostgresTriggerService.setPostgresTriggerMode({ + path, + workspace: $workspaceStore!, + requestBody: { mode, force } + }), + 'postgres trigger' + ) + if (ok) { + sendUserToast(`${capitalize(mode)} postgres trigger ${path}`) + loadTriggers() + } + committed = ok } catch (err) { sendUserToast(`Cannot change postgres trigger mode: ${err.body}`, true) - } finally { loadTriggers() } + return committed } run(() => { diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index b74c7ba0f4..68974c0c29 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -243,6 +243,8 @@ async function onToggleMode(path: string, mode: TriggerMode): Promise { try { + // HTTP routes are always workspace-prefixed at runtime, so fork + // and parent live at distinct URLs — no fork-conflict warning. await HttpTriggerService.setHttpTriggerMode({ path, workspace: $workspaceStore!, diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index e8cd9b7287..69e9ff0b96 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -633,6 +633,7 @@ nestedTopBranchOrIterationN={restart.nestedRestartTopBranchOrIterationN} presetIterationN={restart.topLevelLoopIteration} iterationCounts={restart.iterationCounts} + nestedPathIterationCounts={restart.nestedPathIterationCounts} onRestartComplete={(newJobId) => { goto('/run/' + newJobId + '?workspace=' + $workspaceStore) }} diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte index 4449293182..b9731394a0 100644 --- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte @@ -6,6 +6,7 @@ WorkspaceService } from '$lib/gen' import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import CenteredPage from '$lib/components/CenteredPage.svelte' import { Badge, Button, Skeleton } from '$lib/components/common' @@ -140,16 +141,41 @@ loadingSchedulesWithJobStats = false } + // Per-path counter bumped when a schedule toggle is cancelled or errors, + // to force-remount that row's . Toggle uses `bind:checked` on + // its native input; once the user clicks, the local checkbox state + // diverges from the parent's prop expression, and Svelte 5 prop + // reactivity won't push a same-valued prop back down. Re-mounting + // re-initializes from the prop. List-page rows don't optimistically + // flip `enabled`, so they need this nudge — but only the affected row, + // not all rows on the page. + let toggleResetVersions = $state>({}) + function bumpToggleReset(path: string) { + toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1 + } + async function setScheduleEnabled(path: string, enabled: boolean): Promise { try { - await ScheduleService.setScheduleEnabled({ - path, - workspace: $workspaceStore!, - requestBody: { enabled } - }) - loadSchedules() + const ok = await withForkConflictRetry( + (force) => + ScheduleService.setScheduleEnabled({ + path, + workspace: $workspaceStore!, + requestBody: { enabled, force } + }), + 'schedule' + ) + if (ok) { + loadSchedules() + } else { + // Cancelled — nothing changed on the server, skip the reload + // (which would re-fetch job stats and flash the loading flag) + // and just nudge the toggle back to the prop value. + bumpToggleReset(path) + } } catch (err) { sendUserToast(`Cannot ` + (enabled ? 'enable' : 'disable') + ` schedule: ${err.body}`, true) + bumpToggleReset(path) loadSchedules() } } @@ -416,16 +442,24 @@ {/if}
- { - if (canWrite) { - setScheduleEnabled(path, e.detail) - } else { - sendUserToast('not enough permission', true) - } - }} - /> + {#key toggleResetVersions[path] ?? 0} + { + if (canWrite) { + setScheduleEnabled(path, e.detail) + } else { + sendUserToast('not enough permission', true) + // Permission denied — bump the row's reset + // counter so the Toggle remounts back to the + // prop value. Without this, the local + // `bind:checked` flip from the user's click + // stays stuck on. + bumpToggleReset(path) + } + }} + /> + {/key}