mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
Merge remote-tracking branch 'origin/main' into feat/asset-graph-view
# Conflicts: # CHANGELOG.md # backend/Cargo.lock # backend/Cargo.toml # backend/ee-repo-ref.txt # backend/parsers/windmill-parser-wasm/Cargo.lock # backend/parsers/windmill-parser-wasm/Cargo.toml # backend/tests/worker.rs # backend/windmill-api/openapi.yaml # backend/windmill-api/src/apps.rs # backend/windmill-worker/src/worker.rs # benchmarks/lib.ts # cli/src/main.ts # frontend/package-lock.json # frontend/package.json # lsp/Pipfile # openflow.openapi.yaml # powershell-client/WindmillClient/WindmillClient.psd1 # python-client/wmill/pyproject.toml # typescript-client/jsr.json # typescript-client/package.json # version.txt
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
Generated
+81
-81
@@ -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",
|
||||
|
||||
+2
-2
@@ -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 <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
26184ab7a4aadfc529dcedf038aa08d36c7ad381
|
||||
967f961f0a88b027d894aebd03977181129477a8
|
||||
|
||||
+24
-24
@@ -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",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.693.4"
|
||||
version = "1.694.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
};
|
||||
|
||||
@@ -678,7 +678,6 @@ pub async fn resolve_opt_job_authed(
|
||||
path,
|
||||
method,
|
||||
) {
|
||||
BRUTE_FORCE_COUNTER.increment().await;
|
||||
return Err((err, parts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)));
|
||||
|
||||
@@ -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
|
||||
)))
|
||||
|
||||
@@ -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<String> = 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<bool> = 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)]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -255,6 +255,8 @@ pub struct PolicyTriggerableInputs {
|
||||
delete_after_secs: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
sensitive_inputs: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -2090,6 +2092,11 @@ async fn execute_component(
|
||||
let path = path.to_path();
|
||||
let (arc_policy, policy): (Arc<Policy>, 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/<sha>`
|
||||
// path that has no app_script entry yet.
|
||||
let resolved_inline_tag = |client_tag: Option<String>| -> Option<String> {
|
||||
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/<sha>`-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!(),
|
||||
|
||||
@@ -119,6 +119,45 @@ pub fn is_none_or_false(val: &Option<bool>) -> 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<Vec<&'static str>> {
|
||||
if is_fork {
|
||||
Some(vec!["mode", "enabled"])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn fork_schedule_ignore_keys(is_fork: bool) -> Option<Vec<&'static str>> {
|
||||
if is_fork {
|
||||
Some(vec!["enabled"])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
enum ArchiveImpl {
|
||||
#[cfg(feature = "zip")]
|
||||
Zip(async_zip::tokio::write::ZipFileWriter<tokio::fs::File>),
|
||||
@@ -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
|
||||
|
||||
@@ -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<Self::TriggerConfig>;
|
||||
type TriggerConfig = ();
|
||||
type TriggerConfigRequest = ();
|
||||
type TestConnectionConfig = ();
|
||||
|
||||
@@ -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<bool>,
|
||||
) -> Result<bool> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<Self::TriggerConfig>;
|
||||
type TriggerConfig = ();
|
||||
type TriggerConfigRequest = ();
|
||||
type TestConnectionConfig = ();
|
||||
|
||||
@@ -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<Self::TriggerConfig>;
|
||||
type TriggerConfig = ();
|
||||
type TriggerConfigRequest = ();
|
||||
type TestConnectionConfig = ();
|
||||
|
||||
@@ -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<String>) -> DeployedObject;
|
||||
|
||||
@@ -557,7 +566,7 @@ async fn update_trigger<T: TriggerCrud>(
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((workspace_id, path)): Path<(String, StripPath)>,
|
||||
Json(edit_trigger): Json<TriggerData<T::TriggerConfigRequest>>,
|
||||
Json(mut edit_trigger): Json<TriggerData<T::TriggerConfigRequest>>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || {
|
||||
@@ -574,6 +583,24 @@ async fn update_trigger<T: TriggerCrud>(
|
||||
|
||||
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<TriggerMode> = 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<T: TriggerCrud>(
|
||||
#[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<Option<String>> {
|
||||
let parent: Option<String> =
|
||||
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<bool> = 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<T: TriggerCrud>(
|
||||
@@ -746,6 +819,28 @@ async fn set_trigger_mode<T: TriggerCrud>(
|
||||
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?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -478,7 +478,7 @@ pub async fn process_result(
|
||||
duration: Option<i64>,
|
||||
has_stream: bool,
|
||||
flow_runners: Option<Arc<FlowRunners>>,
|
||||
) -> error::Result<bool> {
|
||||
) -> error::Result<crate::worker::JobOutcome> {
|
||||
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::<serde_json::Value>(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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool>) {
|
||||
/// `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<JobOutcome>) {
|
||||
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<PrecomputedAgentInfo>,
|
||||
flow_runners: Option<Arc<FlowRunners>>,
|
||||
#[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter,
|
||||
) -> windmill_common::error::Result<bool> {
|
||||
) -> windmill_common::error::Result<JobOutcome> {
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -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:<kind>:<parent_workspace_id>`. */
|
||||
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("<file_path:string> <remote_path:string>")
|
||||
.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("<path:string>")
|
||||
.action(enable as any)
|
||||
.command("disable", "Disable a schedule")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6756,6 +6756,7 @@ schedule related commands
|
||||
- \`schedule new <path:string>\` - create a new schedule locally
|
||||
- \`schedule push <file_path:string> <remote_path:string>\` - push a local schedule spec. This overrides any remote versions.
|
||||
- \`schedule enable <path:string>\` - 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 <path:string>\` - Disable a schedule
|
||||
- \`schedule set-permissioned-as <path:string> <email:string>\` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)
|
||||
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
@@ -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:<kind>:<parent_workspace_id>
|
||||
```
|
||||
|
||||
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/<workspace_id>/...`; 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.
|
||||
Generated
+2
-2
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.693.4",
|
||||
"version": "1.694.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -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<Record<string, CiTestResult[]>>({})
|
||||
@@ -627,222 +632,222 @@
|
||||
})
|
||||
|
||||
let forkTriggers = $state<ForkTrigger[]>([])
|
||||
let triggerToDelete = $state<ForkTrigger | undefined>(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<string | undefined>(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<string, any> = {}
|
||||
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<string, { fork?: any; parent?: any }>()
|
||||
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}
|
||||
<span class="text-emphasis">{getTriggerDisplayName(t.triggerKind)}</span>
|
||||
<span class="text-secondary mx-1">→</span>
|
||||
{#if t.extraLabel}
|
||||
<span class="text-secondary ml-1">{t.extraLabel}</span>
|
||||
{/if}
|
||||
<span class="text-tertiary mx-1">→</span>
|
||||
<span class="text-secondary">{t.scriptPath}</span>
|
||||
{:else}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
@@ -1120,30 +1153,38 @@
|
||||
{#if item.trigger}
|
||||
{@const t = item.trigger as ForkTrigger}
|
||||
{@const key = item.key}
|
||||
<Badge color="indigo" size="xs">Fork-only</Badge>
|
||||
{#if t.changeKind === 'new'}
|
||||
<Badge
|
||||
title={mergeIntoParent
|
||||
? `Only exists in '${currentWorkspaceId}'`
|
||||
: `Only exists in '${parentWorkspaceId}'`}
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if t.isFlow}
|
||||
<Badge color="blue" size="xs">flow</Badge>
|
||||
{/if}
|
||||
{#if t.extraLabel}
|
||||
<span class="text-tertiary text-xs">({t.extraLabel})</span>
|
||||
{/if}
|
||||
{#if t.enabled != null}
|
||||
<Badge color={t.enabled ? 'green' : 'gray'} size="xs">
|
||||
{t.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Eye }}
|
||||
onclick={() => openTriggerDetails(t)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" color="red" onclick={() => deleteTrigger(t)}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
{#if mergeIntoParent}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
1 ahead
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
1 behind
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if t.changeKind === 'modified'}
|
||||
<div>
|
||||
<Button size="xs" variant="subtle" onclick={() => openTriggerDiff(t)}>
|
||||
<DiffIcon class="w-3 h-3" />
|
||||
Show diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
@@ -1326,30 +1367,30 @@
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
||||
|
||||
<ScheduleEditor bind:this={scheduleEditor} />
|
||||
<RouteEditor bind:this={routeEditor} />
|
||||
<WebsocketTriggerEditor bind:this={websocketEditor} />
|
||||
<KafkaTriggerEditor bind:this={kafkaEditor} />
|
||||
<PostgresTriggerEditor bind:this={postgresEditor} />
|
||||
<NatsTriggerEditor bind:this={natsEditor} />
|
||||
<MqttTriggerEditor bind:this={mqttEditor} />
|
||||
<SqsTriggerEditor bind:this={sqsEditor} />
|
||||
<GcpTriggerEditor bind:this={gcpEditor} />
|
||||
<AzureTriggerEditor bind:this={azureEditor} />
|
||||
<EmailTriggerEditor bind:this={emailEditor} />
|
||||
|
||||
<ConfirmationModal
|
||||
title="Delete trigger"
|
||||
confirmationText="Delete"
|
||||
open={!!triggerToDelete}
|
||||
onConfirmed={confirmDeleteTrigger}
|
||||
onCanceled={() => (triggerToDelete = undefined)}
|
||||
>
|
||||
{#if triggerToDelete}
|
||||
Are you sure you want to delete the {getTriggerDisplayName(triggerToDelete.triggerKind)} trigger
|
||||
'{triggerToDelete.path}'?
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
<Drawer bind:open={triggerDiffOpen} size="900px">
|
||||
<DrawerContent
|
||||
title={triggerDiffPayload
|
||||
? `${triggerDiffPayload.kindLabel} ${triggerDiffPayload.path}`
|
||||
: 'Trigger diff'}
|
||||
on:close={() => (triggerDiffOpen = false)}
|
||||
>
|
||||
{#if triggerDiffPayload}
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 min-h-0">
|
||||
<DiffEditor
|
||||
open={triggerDiffOpen}
|
||||
className="!h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={triggerDiffPayload.original}
|
||||
defaultModified={triggerDiffPayload.modified}
|
||||
readOnly
|
||||
inlineDiff={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-gray-500">No comparison data available</div>
|
||||
|
||||
@@ -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 `<select>` of `0..count-1` instead of a free-form
|
||||
* number input — same surface as the graph's iteration tabs.
|
||||
* number input — same surface as the graph's iteration tabs. Used for the
|
||||
* SELECTED step's iteration picker when it is itself a top-level ForLoop.
|
||||
*/
|
||||
iterationCounts?: Record<string, number>
|
||||
/**
|
||||
* Iteration counts for nested-path entries, keyed by the popup's field-key
|
||||
* (`'top'` for the outer container, `'inner-N'` for nested ancestors).
|
||||
* Populated by the composable so each entry uses the *correct* graph-state
|
||||
* key (prefixed for in-subflow ancestors), avoiding collisions when the
|
||||
* same step id appears at multiple nesting levels.
|
||||
*/
|
||||
nestedPathIterationCounts?: Record<string, number>
|
||||
/** Called when flow is restarted. If not provided, will navigate to the new run using goto (requires SvelteKit) */
|
||||
onRestart?: (stepId: string, branchOrIterationN: number, flowVersion?: number) => void
|
||||
/** Called when flow restart completes with the new job ID. Used for navigation in non-SvelteKit contexts */
|
||||
@@ -65,6 +74,7 @@
|
||||
nestedTopBranchOrIterationN = undefined,
|
||||
presetIterationN = undefined,
|
||||
iterationCounts = undefined,
|
||||
nestedPathIterationCounts = undefined,
|
||||
onRestart,
|
||||
onRestartComplete
|
||||
}: Props = $props()
|
||||
@@ -303,7 +313,7 @@
|
||||
{/if}
|
||||
|
||||
{#each iterationFields as field (field.key)}
|
||||
{@const count = iterationCounts?.[field.label] ?? 0}
|
||||
{@const count = nestedPathIterationCounts?.[field.key] ?? 0}
|
||||
<label>
|
||||
<div class="pb-1 text-xs font-semibold text-emphasis">
|
||||
From iteration # of <code class="text-xs">{field.label}</code>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { forkConflictModal } from '$lib/stores'
|
||||
|
||||
const state = $derived(forkConflictModal.val)
|
||||
|
||||
// Three failure shapes we want to describe accurately:
|
||||
// - split-events: shared upstream subscription/queue, two listeners halve each other's traffic
|
||||
// - duplicate-firing: independent triggers on shared input, every event fires twice
|
||||
// - slot-takeover: PG replication slot is exclusive AND its position is destructively shared
|
||||
const SPLIT_KINDS = new Set(['kafka', 'nats', 'mqtt', 'sqs', 'gcp', 'azure'])
|
||||
const DUPLICATE_KINDS = new Set(['websocket', 'schedule'])
|
||||
|
||||
const family = $derived(
|
||||
!state
|
||||
? 'unknown'
|
||||
: state.kind === 'postgres'
|
||||
? 'slot'
|
||||
: SPLIT_KINDS.has(state.kind)
|
||||
? 'split'
|
||||
: DUPLICATE_KINDS.has(state.kind)
|
||||
? 'duplicate'
|
||||
: 'unknown'
|
||||
)
|
||||
|
||||
function close(confirmed: boolean) {
|
||||
const resolve = state?.resolve
|
||||
forkConflictModal.val = undefined
|
||||
resolve?.(confirmed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
open={!!state}
|
||||
title="Enable in fork conflicts with parent"
|
||||
confirmationText="Enable anyway"
|
||||
onConfirmed={() => close(true)}
|
||||
onCanceled={() => close(false)}
|
||||
>
|
||||
{#if state}
|
||||
<p>
|
||||
The parent workspace (<span class="font-mono">{state.parentWorkspaceId}</span>) has the same
|
||||
{state.kindLabel} configured at this path. Because this fork's row was cloned from it, the upstream
|
||||
identifier is shared.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
{#if family === 'split'}
|
||||
If both are enabled, the two listeners will compete on the same upstream and each side will
|
||||
receive only a fraction of its events.
|
||||
{:else if family === 'duplicate'}
|
||||
If both are enabled, every event will fire the script twice — once in the fork and once in
|
||||
the parent.
|
||||
{:else if family === 'slot'}
|
||||
The cloned <span class="font-mono">replication_slot_name</span> points at the same Postgres slot,
|
||||
which only allows one consumer at a time. Enabling here will either fail with "slot already active"
|
||||
if the parent is enabled, or hijack the slot's WAL position if it isn't — causing the parent
|
||||
to lose events when re-enabled.
|
||||
{:else}
|
||||
Enabling it here may compete for the same upstream events or duplicate side effects.
|
||||
{/if}
|
||||
</p>
|
||||
<p class="mt-2">Enable in this fork anyway?</p>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
@@ -531,7 +531,8 @@
|
||||
const old = assets?.find((a) => assetEq(a, asset))
|
||||
if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type
|
||||
}
|
||||
if (!deepEqual(assets, newAssets)) assets = newAssets
|
||||
const normalizedAssets = newAssets.length > 0 ? newAssets : undefined
|
||||
if (!deepEqual(assets, normalizedAssets)) assets = normalizedAssets
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -210,7 +210,8 @@ export async function processRunnable(
|
||||
{
|
||||
static_inputs: staticInputs,
|
||||
one_of_inputs: oneOfInputs,
|
||||
allow_user_resources: allowUserResources
|
||||
allow_user_resources: allowUserResources,
|
||||
...(runnable.inlineScript?.tag ? { tag: runnable.inlineScript.tag } : {})
|
||||
}
|
||||
]
|
||||
} else if (isRunnableByPath(runnable)) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type TriggerableV2 = {
|
||||
allow_user_resources?: string[]
|
||||
delete_after_secs?: number
|
||||
sensitive_inputs?: string[]
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export async function hash(message) {
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
() => $workspaceStore
|
||||
)
|
||||
$effect(() => {
|
||||
if (inlineScript && inferAssetsRes.current) inlineScript.assets = inferAssetsRes.current?.assets
|
||||
if (!inlineScript || !inferAssetsRes.current || inferAssetsRes.current.status === 'error')
|
||||
return
|
||||
const newAssets = inferAssetsRes.current.assets
|
||||
inlineScript.assets = newAssets.length > 0 ? newAssets : undefined
|
||||
})
|
||||
|
||||
// Debug mode state
|
||||
|
||||
@@ -34,14 +34,15 @@ export type Runnable = RunnableWithInlineScript | undefined
|
||||
function extraFields(
|
||||
runnable: RunnableWithInlineScript,
|
||||
fields: Record<string, any>
|
||||
): Partial<Pick<TriggerableV2, 'delete_after_secs' | 'sensitive_inputs'>> {
|
||||
const out: Partial<Pick<TriggerableV2, 'delete_after_secs' | 'sensitive_inputs'>> = {}
|
||||
): Partial<Pick<TriggerableV2, 'delete_after_secs' | 'sensitive_inputs' | 'tag'>> {
|
||||
const out: Partial<Pick<TriggerableV2, 'delete_after_secs' | 'sensitive_inputs' | 'tag'>> = {}
|
||||
if (typeof runnable.delete_after_secs === 'number' && runnable.delete_after_secs >= 0)
|
||||
out.delete_after_secs = runnable.delete_after_secs
|
||||
const sensitive_inputs = Object.entries(fields)
|
||||
.map(([k, v]) => (v['sensitive'] ? k : undefined))
|
||||
.filter(Boolean) as string[]
|
||||
if (sensitive_inputs.length > 0) out.sensitive_inputs = sensitive_inputs
|
||||
if (runnable.inlineScript?.tag) out.tag = runnable.inlineScript.tag
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
extra?: Snippet
|
||||
onDelete?: () => void
|
||||
onReset?: () => void
|
||||
onToggleMode: (mode: TriggerMode) => void
|
||||
onToggleMode: (mode: TriggerMode) => void | boolean | Promise<void | boolean>
|
||||
onUpdate?: () => void
|
||||
cloudDisabled?: boolean
|
||||
trigger?: Trigger
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
|
||||
interface Props {
|
||||
triggerMode: TriggerMode
|
||||
onToggleMode: (mode: TriggerMode) => void
|
||||
// Optionally returns false to signal that the change was cancelled
|
||||
// (e.g. user dismissed a fork-conflict modal). When the parent didn't
|
||||
// optimistically update `triggerMode` (list-page rows fall in this
|
||||
// bucket), we resync the local toggle state from the prop after the
|
||||
// dispatch resolves.
|
||||
onToggleMode: (mode: TriggerMode) => void | boolean | Promise<void | boolean>
|
||||
canWrite: boolean
|
||||
hideToggleLabels?: boolean
|
||||
hideDropdown?: boolean
|
||||
@@ -33,7 +38,18 @@
|
||||
includeModalConfig
|
||||
}: Props = $props()
|
||||
|
||||
let innerTriggerMode = $derived(triggerMode)
|
||||
// Local writable state mirroring `triggerMode`. Both inner controls bind
|
||||
// to it: the suspended ToggleButtonGroup directly (it already needed a
|
||||
// writable to revert to 'suspended' when there are queued jobs), and the
|
||||
// regular Toggle via a function binding that maps 'enabled'/'disabled'
|
||||
// to a boolean. Either control can write back here, and the $effect
|
||||
// re-syncs us from the prop whenever the parent updates `triggerMode`.
|
||||
// This lets the parent reset us in cases where the user click can't go
|
||||
// through (e.g. cancelled fork-conflict modal).
|
||||
let innerTriggerMode = $state(triggerMode)
|
||||
$effect(() => {
|
||||
innerTriggerMode = triggerMode
|
||||
})
|
||||
let suspendedJobsModal = $derived(passedSuspendedJobsModal ?? null)
|
||||
</script>
|
||||
|
||||
@@ -75,9 +91,18 @@
|
||||
<Toggle
|
||||
disabled={!canWrite}
|
||||
options={hideToggleLabels ? undefined : { right: 'enable', left: 'disable' }}
|
||||
checked={triggerMode === 'enabled'}
|
||||
on:change={(e) => {
|
||||
onToggleMode(e.detail ? 'enabled' : 'disabled')
|
||||
bind:checked={
|
||||
() => innerTriggerMode === 'enabled', (v) => (innerTriggerMode = v ? 'enabled' : 'disabled')
|
||||
}
|
||||
on:change={async (e) => {
|
||||
const result = await onToggleMode(e.detail ? 'enabled' : 'disabled')
|
||||
if (result === false) {
|
||||
// Cancelled: snap the toggle back to the prop. Needed for
|
||||
// list-page rows where the parent doesn't optimistically
|
||||
// flip `triggerMode` (so $effect won't re-run from a no-op
|
||||
// prop change).
|
||||
innerTriggerMode = triggerMode
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if !hideDropdown}
|
||||
@@ -87,9 +112,18 @@
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
triggerMode = 'suspended'
|
||||
onToggleMode?.('suspended')
|
||||
action: async () => {
|
||||
// Optimistically flip the local mirror, not the
|
||||
// non-bindable `triggerMode` prop. The parent will
|
||||
// echo the new mode back via $effect on success;
|
||||
// on cancel (e.g. user dismisses the fork-conflict
|
||||
// modal), reset to whatever the prop says — same
|
||||
// shape as the Toggle's on:change handler.
|
||||
innerTriggerMode = 'suspended'
|
||||
const result = await onToggleMode?.('suspended')
|
||||
if (result === false) {
|
||||
innerTriggerMode = triggerMode
|
||||
}
|
||||
},
|
||||
tooltip:
|
||||
'When a trigger is in suspended mode, it will continue to accept payloads and queue jobs, but those jobs will not run automatically. You can review the list of suspended jobs, and resume or cancel them individually.'
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
triggerPath: string
|
||||
triggerKind: JobTriggerKind
|
||||
hasChanged: boolean
|
||||
onToggleMode: (mode: TriggerMode) => void
|
||||
onToggleMode: (mode: TriggerMode) => void | boolean | Promise<void | boolean>
|
||||
runnableConfig: TriggerRunnableConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import {
|
||||
@@ -263,13 +264,22 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await AzureTriggerService.setAzureTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
AzureTriggerService.setAzureTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'Azure trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} Azure trigger ${initialPath}`)
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
// Email addresses are always workspace-prefixed (clone filter
|
||||
// excludes workspaced_local_part=false) — no fork-conflict warning.
|
||||
await EmailTriggerService.setEmailTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import {
|
||||
@@ -278,15 +279,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await GcpTriggerService.setGcpTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
GcpTriggerService.setGcpTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'GCP Pub/Sub trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} GCP Pub/Sub trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -408,6 +408,8 @@
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
// HTTP routes are always workspace-prefixed at runtime, so fork
|
||||
// and parent live at distinct URLs — no fork-conflict warning.
|
||||
await HttpTriggerService.setHttpTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2, RotateCcw } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -316,15 +317,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await KafkaTriggerService.setKafkaTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
KafkaTriggerService.setKafkaTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'Kafka trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} Kafka trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -302,15 +303,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await MqttTriggerService.setMqttTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
MqttTriggerService.setMqttTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'MQTT trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} MQTT trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -282,15 +283,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await NatsTriggerService.setNatsTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
NatsTriggerService.setNatsTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'NATS trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} NATS trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -443,15 +444,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await PostgresTriggerService.setPostgresTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
PostgresTriggerService.setPostgresTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'postgres trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} postgres trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
|
||||
import { runScheduleNow } from '../scheduled/utils'
|
||||
import { handleConfigChange } from '../utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import PermissionedAsLine from '../PermissionedAsLine.svelte'
|
||||
@@ -627,13 +628,22 @@
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(nEnabled: boolean) {
|
||||
const previousEnabled = enabled
|
||||
enabled = nEnabled
|
||||
if (!trigger?.draftConfig) {
|
||||
await ScheduleService.setScheduleEnabled({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { enabled: nEnabled }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
ScheduleService.setScheduleEnabled({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { enabled: nEnabled, force }
|
||||
}),
|
||||
'schedule'
|
||||
)
|
||||
if (!ok) {
|
||||
enabled = previousEnabled
|
||||
return
|
||||
}
|
||||
sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${initialPath}`)
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import {
|
||||
@@ -240,15 +241,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await SqsTriggerService.setSqsTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
SqsTriggerService.setSqsTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'SQS trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} SQS trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2, X, Plus } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -370,15 +371,23 @@
|
||||
}
|
||||
|
||||
async function handleToggleMode(newMode: TriggerMode) {
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
await WebsocketTriggerService.setWebsocketTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
WebsocketTriggerService.setWebsocketTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'websocket trigger'
|
||||
)
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
}
|
||||
sendUserToast(`${capitalize(newMode)} websocket trigger ${initialPath}`)
|
||||
|
||||
onUpdate?.(initialPath)
|
||||
}
|
||||
if (originalConfig) {
|
||||
|
||||
@@ -73,6 +73,11 @@ export function useNestedRestartState(opts: {
|
||||
let nestedRestartTopBranchOrIterationN: number | undefined = $state(undefined)
|
||||
let nestedRestartPath: NestedRestartStep[] | undefined = $state(undefined)
|
||||
let nestedRestartSupported = $state(false)
|
||||
// Iteration counts keyed by the popup's field-key ('top' or 'inner-N').
|
||||
// Built during path construction so each entry uses the *correct* graph key
|
||||
// for its level (prefixed for in-subflow ancestors). Avoids the collision
|
||||
// that would happen if we double-indexed `iterationCounts` by bare step_id.
|
||||
let nestedPathIterationCounts: Record<string, number> = $state({})
|
||||
|
||||
$effect(() => {
|
||||
const selectedJobStep = opts.selectedJobStep()
|
||||
@@ -86,6 +91,7 @@ export function useNestedRestartState(opts: {
|
||||
nestedRestartSupported = false
|
||||
restartBranchNames = []
|
||||
selectedJobStepIsTopLevel = undefined
|
||||
nestedPathIterationCounts = {}
|
||||
|
||||
if (selectedJobStep === undefined || job?.flow_status?.modules === undefined) {
|
||||
return
|
||||
@@ -151,24 +157,40 @@ export function useNestedRestartState(opts: {
|
||||
if (!blocked) {
|
||||
// Inside the subflow, the graph state for ForLoop ancestors
|
||||
// is keyed by the prefixed graph id. Build that key here so
|
||||
// `selectedForloopIndex` lookups land on the right entry.
|
||||
// `selectedForloopIndex` and `flow_jobs.length` lookups land
|
||||
// on the right entry (avoiding collisions with same-step-id
|
||||
// loops in the parent flow — e.g. parent has `e` with 4 iters
|
||||
// and the subflow also has `e` with 1 iter).
|
||||
const subflowPrefix = 'subflow:' + subflowParse.subflowSteps.join(':') + ':'
|
||||
const iterationFor = (stepId: string): number =>
|
||||
graphModuleStates[subflowPrefix + stepId]?.selectedForloopIndex ?? 0
|
||||
for (const a of path.ancestors) {
|
||||
const iterCountFor = (stepId: string): number =>
|
||||
graphModuleStates[subflowPrefix + stepId]?.flow_jobs?.length ?? 0
|
||||
const counts: Record<string, number> = {}
|
||||
// Top-level (parent) container is the subflow step `h` —
|
||||
// not a ForLoop, no count.
|
||||
for (let i = 0; i < path.ancestors.length; i++) {
|
||||
const a = path.ancestors[i]
|
||||
const entry: NestedRestartStep = { step_id: a.stepId }
|
||||
if (a.type === 'forloopflow') {
|
||||
entry.branch_or_iteration_n = iterationFor(a.stepId)
|
||||
// Path key: each subflow boundary already contributes an
|
||||
// inner-N entry above (subflow steps without iterations).
|
||||
// Match the index that FlowRestartButton will use when
|
||||
// rendering iteration fields.
|
||||
counts[`inner-${innerPath.length}`] = iterCountFor(a.stepId)
|
||||
}
|
||||
innerPath.push(entry)
|
||||
}
|
||||
const leafEntry: NestedRestartStep = { step_id: subflowParse.leaf }
|
||||
if (path.target.value.type === 'forloopflow') {
|
||||
leafEntry.branch_or_iteration_n = iterationFor(subflowParse.leaf)
|
||||
counts[`inner-${innerPath.length}`] = iterCountFor(subflowParse.leaf)
|
||||
}
|
||||
innerPath.push(leafEntry)
|
||||
nestedRestartTopStepId = top.id
|
||||
nestedRestartPath = innerPath
|
||||
nestedPathIterationCounts = counts
|
||||
nestedRestartSupported = true
|
||||
return
|
||||
}
|
||||
@@ -209,13 +231,17 @@ export function useNestedRestartState(opts: {
|
||||
// for confirmation/editing before submit, so we never silently send a guess.
|
||||
const iterationFor = (stepId: string): number =>
|
||||
graphModuleStates[stepId]?.selectedForloopIndex ?? 0
|
||||
const iterCountFor = (stepId: string): number =>
|
||||
graphModuleStates[stepId]?.flow_jobs?.length ?? 0
|
||||
const top = path.ancestors[0]
|
||||
const inner = path.ancestors.slice(1)
|
||||
const innerPath: NestedRestartStep[] = []
|
||||
const counts: Record<string, number> = {}
|
||||
for (const a of inner) {
|
||||
const entry: NestedRestartStep = { step_id: a.stepId }
|
||||
if (a.type === 'forloopflow') {
|
||||
entry.branch_or_iteration_n = iterationFor(a.stepId)
|
||||
counts[`inner-${innerPath.length}`] = iterCountFor(a.stepId)
|
||||
}
|
||||
innerPath.push(entry)
|
||||
}
|
||||
@@ -224,13 +250,17 @@ export function useNestedRestartState(opts: {
|
||||
const leafEntry: NestedRestartStep = { step_id: selectedJobStep }
|
||||
if (path.target.value.type === 'forloopflow') {
|
||||
leafEntry.branch_or_iteration_n = iterationFor(selectedJobStep)
|
||||
counts[`inner-${innerPath.length}`] = iterCountFor(selectedJobStep)
|
||||
}
|
||||
innerPath.push(leafEntry)
|
||||
|
||||
nestedRestartTopStepId = top.stepId
|
||||
nestedRestartTopBranchOrIterationN =
|
||||
top.type === 'forloopflow' ? iterationFor(top.stepId) : undefined
|
||||
if (top.type === 'forloopflow') {
|
||||
nestedRestartTopBranchOrIterationN = iterationFor(top.stepId)
|
||||
counts['top'] = iterCountFor(top.stepId)
|
||||
}
|
||||
nestedRestartPath = innerPath
|
||||
nestedPathIterationCounts = counts
|
||||
nestedRestartSupported = true
|
||||
})
|
||||
|
||||
@@ -262,28 +292,21 @@ export function useNestedRestartState(opts: {
|
||||
return true
|
||||
})
|
||||
|
||||
// Iteration counts indexed by the step id used in `nestedRestartPath`. For
|
||||
// regular nesting that's the bare `step_id`; for steps inside expanded
|
||||
// subflows we ALSO index by the leaf segment of the prefixed graph id (e.g.
|
||||
// `subflow:h:loop_1` → also indexed at `loop_1`) so the popup can find the
|
||||
// count regardless of which path produced the field.
|
||||
//
|
||||
// Known caveat: if the same step id appears at multiple levels (e.g. a
|
||||
// top-level `loop_1` AND a `loop_1` inside a subflow), the last-writer-wins
|
||||
// here may show the wrong option count. The submitted value is still 0-based
|
||||
// and the backend validates it, so a wrong-count display only affects how
|
||||
// many options the `<select>` 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:...:<step_id>` 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<string, number> => {
|
||||
const out: Record<string, number> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +132,25 @@ export const codeCompletionSessionEnabled = writable<boolean>(
|
||||
export const usedTriggerKinds = writable<string[]>([])
|
||||
|
||||
export let globalDbManagerDrawer: StateStore<DbManagerUriState | undefined> = { val: undefined }
|
||||
export let globalForkModal: StateStore<GlobalForkModalState | undefined> = createState({ val: undefined })
|
||||
export let globalForkModal: StateStore<GlobalForkModalState | undefined> = 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<ForkConflictModalState | undefined> = createState({
|
||||
val: undefined
|
||||
})
|
||||
|
||||
type SQLBaseSchema = {
|
||||
[schemaKey: string]: {
|
||||
[tableKey: string]: {
|
||||
|
||||
@@ -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:<kind>:<parent_workspace_id>`
|
||||
* 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<boolean>((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<unknown>,
|
||||
kindLabel: string
|
||||
): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
<DBManagerDrawer uriState={globalDbManagerDrawer.val} />
|
||||
{/if}
|
||||
|
||||
<ForkConflictModal />
|
||||
|
||||
<Modal2
|
||||
title="Forking {$workspaceStore}"
|
||||
target="#content"
|
||||
|
||||
@@ -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<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -227,6 +227,8 @@
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
|
||||
try {
|
||||
// Email addresses are always workspace-prefixed (clone filter
|
||||
// excludes workspaced_local_part=false) — no fork-conflict warning.
|
||||
await EmailTriggerService.setEmailTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
|
||||
@@ -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<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -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<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -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<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -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<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -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<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -243,6 +243,8 @@
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
|
||||
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!,
|
||||
|
||||
@@ -633,6 +633,7 @@
|
||||
nestedTopBranchOrIterationN={restart.nestedRestartTopBranchOrIterationN}
|
||||
presetIterationN={restart.topLevelLoopIteration}
|
||||
iterationCounts={restart.iterationCounts}
|
||||
nestedPathIterationCounts={restart.nestedPathIterationCounts}
|
||||
onRestartComplete={(newJobId) => {
|
||||
goto('/run/' + newJobId + '?workspace=' + $workspaceStore)
|
||||
}}
|
||||
|
||||
@@ -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>. 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<Record<string, number>>({})
|
||||
function bumpToggleReset(path: string) {
|
||||
toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1
|
||||
}
|
||||
|
||||
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
|
||||
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}
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
on:change={(e) => {
|
||||
if (canWrite) {
|
||||
setScheduleEnabled(path, e.detail)
|
||||
} else {
|
||||
sendUserToast('not enough permission', true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#key toggleResetVersions[path] ?? 0}
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
on:change={(e) => {
|
||||
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}
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
href={`${base}/runs/?schedule_path=${path}&show_schedules=true&show_future_jobs=true`}
|
||||
|
||||
@@ -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'
|
||||
@@ -97,13 +98,20 @@
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
await SqsTriggerService.setSqsTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode }
|
||||
})
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
SqsTriggerService.setSqsTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'SQS trigger'
|
||||
)
|
||||
committed = ok
|
||||
if (ok) loadTriggers()
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
@@ -111,9 +119,9 @@
|
||||
` sqs trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
|
||||
@@ -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'
|
||||
@@ -97,14 +98,23 @@
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
await WebsocketTriggerService.setWebsocketTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode }
|
||||
})
|
||||
sendUserToast(`${capitalize(mode)} websocket trigger ${path}`)
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
WebsocketTriggerService.setWebsocketTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'websocket trigger'
|
||||
)
|
||||
if (ok) {
|
||||
sendUserToast(`${capitalize(mode)} websocket trigger ${path}`)
|
||||
loadTriggers()
|
||||
}
|
||||
committed = ok
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
@@ -112,9 +122,9 @@
|
||||
` websocket trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.693.4"
|
||||
wmill = ">=1.694.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.3'
|
||||
|
||||
info:
|
||||
version: 1.693.4
|
||||
version: 1.694.0
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
RootModule = 'WindmillClient.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '1.693.4'
|
||||
ModuleVersion = '1.694.0'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill"
|
||||
version = "1.693.4"
|
||||
version = "1.694.0"
|
||||
description = "A client library for accessing Windmill server wrapping the Windmill client API"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -388,6 +388,7 @@ schedule related commands
|
||||
- `schedule new <path:string>` - create a new schedule locally
|
||||
- `schedule push <file_path:string> <remote_path:string>` - push a local schedule spec. This overrides any remote versions.
|
||||
- `schedule enable <path:string>` - 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 <path:string>` - Disable a schedule
|
||||
- `schedule set-permissioned-as <path:string> <email:string>` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)
|
||||
|
||||
|
||||
@@ -2536,6 +2536,7 @@ schedule related commands
|
||||
- \`schedule new <path:string>\` - create a new schedule locally
|
||||
- \`schedule push <file_path:string> <remote_path:string>\` - push a local schedule spec. This overrides any remote versions.
|
||||
- \`schedule enable <path:string>\` - 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 <path:string>\` - Disable a schedule
|
||||
- \`schedule set-permissioned-as <path:string> <email:string>\` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ schedule related commands
|
||||
- `schedule new <path:string>` - create a new schedule locally
|
||||
- `schedule push <file_path:string> <remote_path:string>` - push a local schedule spec. This overrides any remote versions.
|
||||
- `schedule enable <path:string>` - 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 <path:string>` - Disable a schedule
|
||||
- `schedule set-permissioned-as <path:string> <email:string>` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill/windmill",
|
||||
"version": "1.693.4",
|
||||
"version": "1.694.0",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-client",
|
||||
"description": "Windmill SDK client for browsers and Node.js",
|
||||
"version": "1.693.4",
|
||||
"version": "1.694.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache 2.0",
|
||||
"sideEffects": false,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.693.4
|
||||
1.694.0
|
||||
|
||||
Reference in New Issue
Block a user