diff --git a/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json new file mode 100644 index 0000000000..71f0c1e2d8 --- /dev/null +++ b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72" +} diff --git a/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json new file mode 100644 index 0000000000..d67ddf711b --- /dev/null +++ b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, (email IS NULL), created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f" +} diff --git a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json index 8944a6001f..dfc8540468 100644 --- a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json +++ b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "id", + "name": "id!", "type_info": "Uuid" } ], @@ -16,7 +16,7 @@ ] }, "nullable": [ - false + null ] }, "hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319" diff --git a/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json new file mode 100644 index 0000000000..900cbdae4d --- /dev/null +++ b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ, value, created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2" +} diff --git a/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json new file mode 100644 index 0000000000..2d22e40cc7 --- /dev/null +++ b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path\n FROM app\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "versions", + "type_info": "Int8Array" + }, + { + "ordinal": 6, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5" +} diff --git a/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json b/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json new file mode 100644 index 0000000000..19fbdd36f9 --- /dev/null +++ b/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4" +} diff --git a/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json new file mode 100644 index 0000000000..66809be416 --- /dev/null +++ b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe" +} diff --git a/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json new file mode 100644 index 0000000000..f1fab7b130 --- /dev/null +++ b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6" +} diff --git a/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json new file mode 100644 index 0000000000..335561cfef --- /dev/null +++ b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81" +} diff --git a/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json new file mode 100644 index 0000000000..ebd8594a06 --- /dev/null +++ b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5" +} diff --git a/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json new file mode 100644 index 0000000000..e432c394b9 --- /dev/null +++ b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c" +} diff --git a/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json new file mode 100644 index 0000000000..be5e26ef9b --- /dev/null +++ b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a" +} diff --git a/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json new file mode 100644 index 0000000000..37f6872cc9 --- /dev/null +++ b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4" +} diff --git a/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json new file mode 100644 index 0000000000..bb326fab87 --- /dev/null +++ b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND (email = $4 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4" +} diff --git a/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json new file mode 100644 index 0000000000..ca75762cd6 --- /dev/null +++ b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json new file mode 100644 index 0000000000..7ecba622e6 --- /dev/null +++ b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "resource_type", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 9, + "name": "is_expired", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_refreshed", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "refresh_error", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "is_linked", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "is_oauth?", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "account", + "type_info": "Int4" + }, + { + "ordinal": 15, + "name": "ws_specific", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "is_draft", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true, + true, + null, + null, + true, + null, + false, + true, + null, + null, + null + ] + }, + "hash": "665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067" +} diff --git a/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json new file mode 100644 index 0000000000..80f172aa40 --- /dev/null +++ b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Bool", + "Bool", + "Int4", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Bool", + "Text", + "Bool", + "Jsonb", + "Varchar", + "Int4", + "Bool", + "Int8", + "Jsonb", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd" +} diff --git a/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json new file mode 100644 index 0000000000..dcff085b5e --- /dev/null +++ b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e" +} diff --git a/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json new file mode 100644 index 0000000000..2b92062ad0 --- /dev/null +++ b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63" +} diff --git a/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json new file mode 100644 index 0000000000..02e17b82cb --- /dev/null +++ b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d" +} diff --git a/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json new file mode 100644 index 0000000000..f4839e8e38 --- /dev/null +++ b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)\n VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e" +} diff --git a/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json new file mode 100644 index 0000000000..9dc1d0f296 --- /dev/null +++ b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d" +} diff --git a/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json new file mode 100644 index 0000000000..00b6e712d5 --- /dev/null +++ b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780" +} diff --git a/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json new file mode 100644 index 0000000000..33e7bfe8ec --- /dev/null +++ b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5" +} diff --git a/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json new file mode 100644 index 0000000000..13402c8387 --- /dev/null +++ b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422" +} diff --git a/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json new file mode 100644 index 0000000000..681b9d7d7d --- /dev/null +++ b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df" +} diff --git a/backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json similarity index 76% rename from backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json rename to backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json index 5de7aed2e0..08dfcfd6bd 100644 --- a/backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json +++ b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", "describe": { "columns": [ { @@ -87,6 +87,16 @@ "ordinal": 16, "name": "ws_specific", "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 18, + "name": "is_draft", + "type_info": "Bool" } ], "parameters": { @@ -112,8 +122,10 @@ null, false, true, + null, + null, null ] }, - "hash": "52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f" + "hash": "9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1" } diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json index f0c91daf16..b492b38f49 100644 --- a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json +++ b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json @@ -13,4 +13,4 @@ "nullable": [] }, "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" -} \ No newline at end of file +} diff --git a/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json new file mode 100644 index 0000000000..52e1ced19a --- /dev/null +++ b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d" +} diff --git a/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json new file mode 100644 index 0000000000..a4ad3fd76b --- /dev/null +++ b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52" +} diff --git a/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json new file mode 100644 index 0000000000..78014926fa --- /dev/null +++ b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2" +} diff --git a/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json new file mode 100644 index 0000000000..65003984ac --- /dev/null +++ b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453" +} diff --git a/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json new file mode 100644 index 0000000000..61c60afdc4 --- /dev/null +++ b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Int8Array", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a" +} diff --git a/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json new file mode 100644 index 0000000000..6d682c7bb7 --- /dev/null +++ b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6" +} diff --git a/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json new file mode 100644 index 0000000000..0eed069163 --- /dev/null +++ b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596" +} diff --git a/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json new file mode 100644 index 0000000000..6ae3f60e49 --- /dev/null +++ b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9" +} diff --git a/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json new file mode 100644 index 0000000000..35e384b496 --- /dev/null +++ b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d" +} diff --git a/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json new file mode 100644 index 0000000000..c077375ef4 --- /dev/null +++ b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text", + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545" +} diff --git a/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json new file mode 100644 index 0000000000..879288cc04 --- /dev/null +++ b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7, $8,\n $9,\n $10, $11::text::json, $12, now(), $13\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5" +} diff --git a/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json new file mode 100644 index 0000000000..319c192fae --- /dev/null +++ b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa" +} diff --git a/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json new file mode 100644 index 0000000000..7ac1fb17da --- /dev/null +++ b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false\n ORDER BY path, created_at DESC\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71" +} diff --git a/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json b/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json new file mode 100644 index 0000000000..b30207b10e --- /dev/null +++ b/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75" +} diff --git a/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json new file mode 100644 index 0000000000..394d36b747 --- /dev/null +++ b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745" +} diff --git a/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json new file mode 100644 index 0000000000..c624143ea4 --- /dev/null +++ b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8" +} diff --git a/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json new file mode 100644 index 0000000000..e8dedf7ab9 --- /dev/null +++ b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11f00f7ad1..8ca8a7e265 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14589,6 +14589,7 @@ dependencies = [ "dashmap", "datafusion", "equivalent", + "erased-serde", "futures", "futures-core", "gethostname", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 70d6eba003..fa76836c89 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a82882f1cb9b1c4cef532f6ad046242903418d29 +97b5cb2096d3a9b4818943c5abf181d914cb4e99 diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.down.sql b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql new file mode 100644 index 0000000000..bbd036ad37 --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql @@ -0,0 +1,25 @@ +ALTER TABLE draft DROP COLUMN id; + +-- The .up.sql secret-draft scrub is irreversible — the blanked plaintext +-- values were deliberately destroyed and cannot be recovered here. + +DROP INDEX IF EXISTS draft_workspace_path_typ_idx; +DROP INDEX IF EXISTS draft_user_listing_idx; +DROP INDEX IF EXISTS draft_pkey_legacy; +DROP INDEX IF EXISTS draft_pkey_with_user; + +-- Per-user rows can't exist under the composite PK (one row per +-- (workspace_id, path, typ)); drop them before restoring it. +DELETE FROM draft WHERE email IS NOT NULL; + +ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ); + +ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey; +ALTER TABLE draft DROP COLUMN email; + +-- Restore the narrower DRAFT_TYPE enum; drop rows outside that set so the +-- cast doesn't fail. +CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); +DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app'); +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE; +DROP TYPE DRAFT_KIND; diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.up.sql b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql new file mode 100644 index 0000000000..07343c6b2c --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql @@ -0,0 +1,82 @@ +-- Reshape `draft` for per-user bidirectional sync: add the owner `email` +-- (FK to password.email, NULL on legacy rows); replace the composite PK with +-- two partial unique indexes so per-user rows and the single legacy +-- workspace-level row coexist at the same (workspace_id, path, typ); widen +-- the DRAFT_TYPE enum to DRAFT_KIND (every UserDraftItemKind); and add a +-- synthetic BIGSERIAL `id` PK (tools like pg_dump/replication break on the +-- partial-index-only layout). + +CREATE TYPE DRAFT_KIND AS ENUM ( + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +); + +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND; +DROP TYPE DRAFT_TYPE; + +ALTER TABLE draft ADD COLUMN email VARCHAR(255); + +ALTER TABLE draft + ADD CONSTRAINT draft_password_fkey + FOREIGN KEY (email) + REFERENCES password(email) + ON DELETE CASCADE + ON UPDATE CASCADE; + +ALTER TABLE draft DROP CONSTRAINT draft_pkey; + +CREATE UNIQUE INDEX draft_pkey_with_user + ON draft (workspace_id, path, typ, email) + WHERE email IS NOT NULL; + +CREATE UNIQUE INDEX draft_pkey_legacy + ON draft (workspace_id, path, typ) + WHERE email IS NULL; + +-- Serves the per-user draft listing (`WHERE workspace_id = ? AND email = ? +-- ORDER BY path`); neither partial unique index helps (both lead with +-- `path, typ`), and the trailing `path` keeps rows in output order. +CREATE INDEX draft_user_listing_idx + ON draft (workspace_id, email, path) + WHERE email IS NOT NULL; + +ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY; + +-- Hot path: `fetch_other_drafts_users` runs on every get-by-path request +-- with `WHERE workspace_id = ? AND path = ? AND typ = ?` and no email +-- predicate. The partial unique/listing indexes can't serve it (their +-- `email IS [NOT] NULL` predicates aren't implied by the query), so a plain +-- btree is needed. Also covers `get_draft_for_user` (same three columns + +-- `email IS NOT DISTINCT FROM ?` as a filter). +CREATE INDEX draft_workspace_path_typ_idx ON draft (workspace_id, path, typ); + +-- Secret variable values must never sit in `draft.value` in plaintext. +-- `save_draft` now encrypts them at write time; this scrubs any rows +-- persisted before that guard (irreversible — see the .down.sql note). +UPDATE draft +SET value = jsonb_set(value::jsonb, '{variable,value}', '""'::jsonb)::json +WHERE typ = 'variable' + AND (value::jsonb -> 'variable' ->> 'is_secret')::boolean IS TRUE + AND value::jsonb -> 'variable' ? 'value'; diff --git a/backend/migrations/20260609165313_remove_draft_only.down.sql b/backend/migrations/20260609165313_remove_draft_only.down.sql new file mode 100644 index 0000000000..9594c93957 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.down.sql @@ -0,0 +1,5 @@ +-- Restore the `draft_only` column. Irreversible data-wise: stubs deleted in +-- the up migration are gone and the column comes back NULL everywhere. +ALTER TABLE script ADD COLUMN draft_only BOOLEAN; +ALTER TABLE flow ADD COLUMN draft_only BOOLEAN; +ALTER TABLE app ADD COLUMN draft_only BOOLEAN; diff --git a/backend/migrations/20260609165313_remove_draft_only.up.sql b/backend/migrations/20260609165313_remove_draft_only.up.sql new file mode 100644 index 0000000000..9949de5540 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.up.sql @@ -0,0 +1,99 @@ +-- `draft_only` items (scripts/flows/apps saved as a draft but never +-- deployed) lived as a stub row in their own table plus a `draft` row; the +-- stub is now redundant. For each stub: ensure a draft row exists at its +-- path (ON CONFLICT DO NOTHING preserves the real per-user draft most stubs +-- already have, synthesising an `email = NULL` legacy stand-in only for the +-- rare stub that lost its draft), drop the stub (version FKs cascade), then +-- drop the `draft_only` column. +-- ON CONFLICT targets `draft_pkey_legacy` — (workspace_id, path, typ) WHERE +-- email IS NULL. + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + s.workspace_id, + s.path, + 'script'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', s.path, + 'summary', s.summary, + 'description', s.description, + 'content', s.content, + 'language', s.language, + 'kind', s.kind, + 'tag', s.tag, + 'schema', s.schema, + 'envs', to_jsonb(s.envs), + 'concurrent_limit', s.concurrent_limit, + 'concurrency_time_window_s', s.concurrency_time_window_s, + 'concurrency_key', s.concurrency_key, + 'cache_ttl', s.cache_ttl, + 'cache_ignore_s3_path', s.cache_ignore_s3_path, + 'dedicated_worker', s.dedicated_worker, + 'ws_error_handler_muted', s.ws_error_handler_muted, + 'priority', s.priority, + 'timeout', s.timeout, + 'delete_after_use', s.delete_after_use, + 'restart_unless_cancelled', s.restart_unless_cancelled, + 'visible_to_runner_only', s.visible_to_runner_only, + 'auto_kind', s.auto_kind, + 'has_preprocessor', s.has_preprocessor, + 'on_behalf_of_email', s.on_behalf_of_email, + 'assets', s.assets, + 'debounce_key', s.debounce_key, + 'debounce_delay_s', s.debounce_delay_s, + 'labels', to_jsonb(s.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM script s +WHERE s.draft_only IS TRUE AND s.deleted IS FALSE AND s.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + f.workspace_id, + f.path, + 'flow'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', f.path, + 'summary', f.summary, + 'description', f.description, + 'value', f.value, + 'schema', f.schema, + 'tag', f.tag, + 'dedicated_worker', f.dedicated_worker, + 'timeout', f.timeout, + 'visible_to_runner_only', f.visible_to_runner_only, + 'on_behalf_of_email', f.on_behalf_of_email, + 'ws_error_handler_muted', f.ws_error_handler_muted, + 'labels', to_jsonb(f.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM flow f +WHERE f.draft_only IS TRUE AND f.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +-- App drafts store the editor's working value directly in `draft.value` (an +-- `App` object for `app`, a `{files, runnables, data}` object for `raw_app`). +-- The deployed wrapper's summary/policy/custom_path aren't part of the App +-- type and aren't restored from a draft on reload, so they're dropped here. +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + a.workspace_id, + a.path, + CASE WHEN av.raw_app THEN 'raw_app'::DRAFT_KIND ELSE 'app'::DRAFT_KIND END, + NULL, + av.value +FROM app a +JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)] +WHERE a.draft_only IS TRUE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +DELETE FROM script WHERE draft_only IS TRUE; +DELETE FROM flow WHERE draft_only IS TRUE; +DELETE FROM app WHERE draft_only IS TRUE; + +ALTER TABLE script DROP COLUMN draft_only; +ALTER TABLE flow DROP COLUMN draft_only; +ALTER TABLE app DROP COLUMN draft_only; diff --git a/backend/tests/ci_tests.rs b/backend/tests/ci_tests.rs index c8551a30a5..46bed97094 100644 --- a/backend/tests/ci_tests.rs +++ b/backend/tests/ci_tests.rs @@ -10,6 +10,7 @@ mod ci_tests { fn quick_ns(content: &str, path: &str, parent_hash: Option) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Python3, lock: None, @@ -20,7 +21,6 @@ mod ci_tests { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 48a50ef9b9..0d58615be0 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -16,6 +16,7 @@ mod dependency_map { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -26,7 +27,6 @@ mod dependency_map { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -441,7 +441,6 @@ def main(): .unwrap(), ), schema: None, - draft_only: None, tag: None, dedicated_worker: None, timeout: None, diff --git a/backend/tests/relock_skip.rs b/backend/tests/relock_skip.rs index eb8dfb261d..8262a38cf4 100644 --- a/backend/tests/relock_skip.rs +++ b/backend/tests/relock_skip.rs @@ -14,6 +14,7 @@ mod relock_skip { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -24,7 +25,6 @@ mod relock_skip { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/script_auto_kind_failure.rs b/backend/tests/script_auto_kind_failure.rs index 4110bd4ba5..05d22e75ec 100644 --- a/backend/tests/script_auto_kind_failure.rs +++ b/backend/tests/script_auto_kind_failure.rs @@ -6,6 +6,7 @@ use windmill_test_utils::init_client; fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Bun, lock: None, @@ -16,7 +17,6 @@ fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: kind.map(|s| s.to_string()), diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index d1c20da407..c3f6357d4c 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -200,7 +200,7 @@ async fn test_handler_queries_websocket(db: Pool) -> anyhow::Result<() assert_eq!(trigger.base.permissioned_as, "u/test-user"); let triggers = handler - .list_triggers(&mut *conn, "test-workspace", None) + .list_triggers(&mut *conn, "test-workspace", None, None) .await?; assert!(triggers.iter().any(|t| t.base.path == "f/test/handler_ws")); diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index f21862fd38..b220416b55 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4178,8 +4178,8 @@ async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { visible_to_runner_only: None, on_behalf_of_email: None, }, - draft_only: None, deployment_message: None, + draft_only: None, }, ) .await diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs index 823ff22a43..ca988562fd 100644 --- a/backend/tests/workspace_export.rs +++ b/backend/tests/workspace_export.rs @@ -40,6 +40,7 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() .create_script( "test-workspace", &windmill_api_client::types::NewScript { + draft_only: None, content: "export function main() { return 42; }".to_string(), language: windmill_api_client::types::ScriptLang::Bun, path: "f/test_folder/test_script".to_string(), @@ -51,7 +52,6 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() parent_hash: None, schema: Default::default(), is_template: None, - draft_only: None, dedicated_worker: None, ws_error_handler_muted: None, priority: None, diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 4a31589baa..5a9087318b 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -21,7 +21,8 @@ use windmill_api_auth::{ }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ - utils::{WithStarredInfoQuery, HTTP_CLIENT}, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, + utils::HTTP_CLIENT, webhook::{WebhookMessage, WebhookShared}, DB, }; @@ -49,7 +50,6 @@ use windmill_common::{ flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, - scripts::Schema, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; @@ -68,7 +68,6 @@ pub fn workspaced_service() -> Router { .route("/list_tokens/{*path}", get(list_tokens)) .route("/get/{*path}", get(get_flow_by_path)) .route("/deployment_status/p/{*path}", get(get_deployment_status)) - .route("/get/draft/{*path}", get(get_flow_by_path_w_draft)) .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) .route("/history/p/{*path}", get(get_flow_history)) @@ -130,6 +129,7 @@ async fn list_search_flows( async fn list_flows( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -151,10 +151,15 @@ async fn list_flows( "archived", "extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "o.labels", + "draft.email IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() @@ -166,7 +171,8 @@ async fn list_flows( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?" + .bind(&authed.email), ) .left() .join("flow_version fv") @@ -195,9 +201,6 @@ async fn list_flows( sqlb.and_where_is_not_null("favorite.path"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("o.draft_only IS NOT TRUE"); - } if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } @@ -221,13 +224,92 @@ async fn list_flows( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "flows", "read"); - let rows = sqlx::query_as::<_, ListableFlow>(&sql) + let mut rows = sqlx::query_as::<_, ListableFlow>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.edited_by.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` + `DISTINCT ON (path)` ordered NULL-last; see scripts.rs. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'flow' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM flow f + WHERE f.workspace_id = draft.workspace_id + AND f.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`), + // so the editor writes a separate `draft_path` field only when the typed + // path differs from the deployed one. `None` = unchanged. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableFlow { + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + edited_by: Some(authed.email.clone()), + edited_at: Some(row.created_at), + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + starred: false, + draft_only: Some(true), + ws_error_handler_muted: None, + deployment_msg: None, + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -517,22 +599,21 @@ async fn create_flow( sqlx::query!( r#"INSERT INTO flow ( workspace_id, path, summary, description, - dependency_job, lock_error_logs, draft_only, tag, + dependency_job, lock_error_logs, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, ws_error_handler_muted, value, schema, edited_by, edited_at, labels ) VALUES ( $1, $2, $3, $4, - NULL, '', $5, $6, - $7, $8, $9, - $10, - $11, $12::text::json, $13, now(), $14 + NULL, '', $5, + $6, $7, $8, + $9, + $10, $11::text::json, $12, now(), $13 )"#, w_id, nf.path, nf.summary, nf.description.as_deref().unwrap_or(""), - nf.draft_only, nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), @@ -571,12 +652,15 @@ async fn create_flow( ).execute(&mut *tx).await?; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", nf.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -719,18 +803,6 @@ async fn check_schedule_conflict<'c>( Ok(()) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", - "flow", - ) - .await; -} - #[derive(Serialize)] pub struct FlowVersion { pub id: i64, @@ -799,7 +871,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -850,7 +922,6 @@ async fn get_flow_version_by_id( flow.description, flow.archived, flow.extra_perms, - flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, @@ -987,7 +1058,6 @@ async fn update_flow( description = $3, dependency_job = NULL, lock_error_logs = '', - draft_only = NULL, tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, @@ -1029,8 +1099,8 @@ async fn update_flow( // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1174,12 +1244,15 @@ async fn update_flow( } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", flow_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1397,12 +1470,21 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetFlowByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + async fn get_flow_by_path( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1416,9 +1498,8 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, @@ -1444,7 +1525,7 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1458,9 +1539,8 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, @@ -1481,90 +1561,26 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct FlowWDraft { - pub path: String, - pub summary: String, - pub description: String, - pub schema: Option, - pub value: sqlx::types::Json>, - pub extra_perms: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, -} - -async fn get_flow_by_path_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let flow_o = sqlx::query_as::<_, FlowWDraft>( - "SELECT - flow.path, - flow.summary, - flow.description, - flow_version.schema, - flow_version.value, - flow.extra_perms, - flow.draft_only, - flow.ws_error_handler_muted, - flow.dedicated_worker, - draft.value AS draft, - draft.created_at AS draft_created_at, - flow.tag, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow.labels - FROM flow - LEFT JOIN draft - ON flow.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'flow' - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 - AND flow.workspace_id = $2", + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Flow, + path, + query.get_draft, + flow_o, + || windmill_common::error::Error::NotFound(format!("Flow not found at path {path}")), ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) .await?; - - tx.commit().await?; - - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) + Ok(Json(overlay)) } async fn exists_flow_by_path( diff --git a/backend/windmill-api-integration-tests/tests/apps.rs b/backend/windmill-api-integration-tests/tests/apps.rs index 63bd96345b..3d70c09ce1 100644 --- a/backend/windmill-api-integration-tests/tests/apps.rs +++ b/backend/windmill-api-integration-tests/tests/apps.rs @@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_app").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_app"); - // --- get lite --- let resp = authed_get(port, "get/lite", "u/test-user/test_app").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index d86c91b6a1..b3bac8d010 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -1,105 +1,297 @@ use serde_json::json; use sqlx::{Pool, Postgres}; - use windmill_test_utils::*; -fn client() -> reqwest::Client { - reqwest::Client::new() +const WS: &str = "test-workspace"; + +/// A reqwest client that sends `Authorization: Bearer `. The base +/// fixture seeds: SECRET_TOKEN (test-user, admin), SECRET_TOKEN_2 +/// (test-user-2, non-admin), SECRET_TOKEN_3 (test-user-3, non-admin). +fn client_for(token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() } -fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - builder.header("Authorization", "Bearer SECRET_TOKEN") +fn save_url(port: u16, kind: &str, path: &str) -> String { + format!("http://localhost:{port}/api/w/{WS}/drafts/update/{kind}/{path}") } +async fn draft_count(db: &Pool, path: &str, kind: &str, email: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = $3::text::DRAFT_KIND AND email = $4", + ) + .bind(WS) + .bind(path) + .bind(kind) + .bind(email) + .fetch_one(db) + .await + .unwrap() +} + +/// Upsert → conflict (stale last_sync) → force-overwrite → delete, the +/// optimistic-concurrency contract `update_draft` exists to enforce. #[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_draft_endpoints(db: Pool) -> anyhow::Result<()> { +async fn test_update_draft_conflict_lifecycle(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); - let base = format!("http://localhost:{port}/api/w/test-workspace/drafts"); + let c = client_for("SECRET_TOKEN"); + let path = "u/test-user/draft_x"; + let url = save_url(port, "script", path); - // create a script first so the draft has a valid path - let resp = authed(client().post(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/create" - ))) - .json(&json!({ - "path": "u/test-user/draft_script", - "summary": "Script for draft test", - "description": "", - "content": "export async function main() { return 1; }", - "language": "deno", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": {}, - "required": [] - } - })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); - - // --- create draft --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 2; }", - "language": "deno" - } - })) + // First save: no last_sync ("treat as fresh") → saved. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 1 } })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?); + .await?; + assert_eq!(r.status(), 200, "first save"); + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved"); + let ts1 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 1 + ); - // verify draft exists via script get/draft endpoint - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_object(), "expected draft to be present"); + // A tiny gap so the next now() is strictly greater than ts1. + tokio::time::sleep(std::time::Duration::from_millis(15)).await; - // --- update draft (create with same path overwrites) --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 3; }", - "language": "deno" - } - })) + // Save with the matching last_sync → not stale → saved, newer ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 2 }, "last_sync": ts1 })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201); + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved", "in-order save"); + let ts2 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_ne!(ts1, ts2, "timestamp should advance"); - // --- delete draft --- - let resp = authed(client().delete(format!( - "{base}/delete/script/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); + // Save with the now-stale ts1 → conflict, server reports its current ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1 })) + .send() + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "conflict", "stale save must conflict"); + assert_eq!(body["current_timestamp"].as_str().unwrap(), ts2); - // verify draft is gone - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_null(), "expected draft to be deleted"); + // The conflicting write must NOT have landed — value is still {n:2}. + let stored: serde_json::Value = sqlx::query_scalar::<_, sqlx::types::Json>( + "SELECT value FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = 'script' AND email = 'test@windmill.dev'", + ) + .bind(WS) + .bind(path) + .fetch_one(&db) + .await? + .0; + assert_eq!(stored["n"], 2, "conflicting write must be rejected"); + + // force = true overrides the conflict check. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1, "force": true })) + .send() + .await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "force" + ); + + // Delete (value: null) → saved, row gone. + let r = c.post(&url).json(&json!({ "value": null })).send().await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "delete" + ); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 0, + "row removed after delete" + ); + + Ok(()) +} + +/// require_can_write_path: own namespace allowed, another user's namespace +/// rejected, operators rejected outright. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let user2 = client_for("SECRET_TOKEN_2"); // test-user-2, non-admin + + // Own namespace → allowed. + let r = user2 + .post(save_url(port, "script", "u/test-user-2/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 200, "own namespace allowed"); + + // Another user's namespace, no grant → rejected. + let r = user2 + .post(save_url(port, "script", "u/test-user/theirs")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "other user's namespace rejected"); + + // Operators can't save drafts at all. + sqlx::query( + "UPDATE usr SET operator = true WHERE workspace_id = $1 AND username = 'test-user-3'", + ) + .bind(WS) + .execute(&db) + .await?; + let op = client_for("SECRET_TOKEN_3"); + let r = op + .post(save_url(port, "script", "u/test-user-3/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "operator rejected"); + + Ok(()) +} + +/// The item-level extra_perms fallback: a user granted write on a deployed +/// item (via the Share dialog) can save a draft on it even though it's +/// outside their namespace. Regression test for the authz drop. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_extra_perms_writer(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let path = "u/test-user/shared"; + + // A deployed script owned by test-user, shared with test-user-2 (write). + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 1, $2, '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": true}'::jsonb, 'test-user')", + ) + .bind(WS) + .bind(path) + .execute(&db) + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + let r = user2 + .post(save_url(port, "script", path)) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!( + r.status(), + 200, + "extra_perms writer can save a draft: {}", + r.text().await? + ); + + // Without a grant on a different shared item → still rejected. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 2, 'u/test-user/private', '', '', 'x', 'deno', '{}'::jsonb, \ + '{}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/private")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "no grant → rejected"); + + // A READ-ONLY grant (`extra_perms` value false) must not allow draft + // saves: the write check defers to RLS via `SELECT ... FOR UPDATE`, + // and locking applies the UPDATE policies — visibility under the + // SELECT policy alone isn't enough. Pins the FOR UPDATE semantics the + // probe relies on. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 3, 'u/test-user/readonly', '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": false}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/readonly")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "read-only grant → rejected"); + + Ok(()) +} + +/// Cross-user draft viewing (`GET /drafts/get/{kind}/{path}`) is disabled +/// for the drawer kinds (resource/variable/triggers) so a viewer can't +/// read another user's draft; it stays available for script/flow/app. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_cross_user_draft_privacy(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // test-user saves a variable draft and a script draft in a shared folder. + let admin = client_for("SECRET_TOKEN"); + admin + .post(save_url(port, "variable", "f/shared/v")) + .json(&json!({ "value": { "variable": { "value": "x", "is_secret": false } } })) + .send() + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + // Variable is a drawer kind → cross-user view is forbidden regardless of + // path access (the kind gate fires first). + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/variable/f/shared/v?username=test-user" + )) + .send() + .await?; + assert_eq!( + r.status(), + 404, + "variable drafts are private to their owner" + ); + + // Sharing kinds (script) are NOT gated by the kind check — a missing + // draft / no access yields 404 too, but the "private to their owner" + // wording is specific to the drawer kinds, so assert it's absent here. + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/script/f/shared/s?username=test-user" + )) + .send() + .await?; + let body = r.text().await?; + assert!( + !body.contains("private to their owner"), + "script kind must not be blocked by the cross-user privacy gate: {body}" + ); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/flows.rs b/backend/windmill-api-integration-tests/tests/flows.rs index b6075c8e69..774c118e7b 100644 --- a/backend/windmill-api-integration-tests/tests/flows.rs +++ b/backend/windmill-api-integration-tests/tests/flows.rs @@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_flow"); - // --- list --- let resp = authed(client().get(format!("{base}/list"))) .send() diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index c374b757a4..e5f6cb8aa8 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { let body = resp.json::().await?; assert_eq!(body["path"], "u/test-user/test_script"); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_script").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_script"); - // --- raw by path (requires language extension) --- let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index 82447daee2..a0f7f7d460 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -92,8 +92,8 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re // Create app sqlx::query!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only) - VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)" + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms) + VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')" ) .execute(&db) .await?; diff --git a/backend/windmill-api-openapi/Cargo.toml b/backend/windmill-api-openapi/Cargo.toml index ee1727c059..24a6f2aed8 100644 --- a/backend/windmill-api-openapi/Cargo.toml +++ b/backend/windmill-api-openapi/Cargo.toml @@ -11,7 +11,12 @@ path = "src/lib.rs" [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } -windmill-store.workspace = true +# `try_get_resource_from_db_as` is used unconditionally below but is +# cfg-gated behind a trigger feature in windmill-store; forward +# `http_trigger` so the import resolves even when this crate's targets are +# built in isolation (e.g. `--all-targets` under resolver 2), not only via +# whole-workspace feature unification. +windmill-store = { workspace = true, features = ["http_trigger"] } windmill-trigger-http.workspace = true anyhow.workspace = true axum.workspace = true diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 35f71e561e..7cc0111c28 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -25,6 +25,10 @@ use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, schedule::Schedule, + user_drafts::{ + delete_all_drafts_for_path, fetch_draft_only_list_rows, overlay_or_draft_only, + UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{ escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath, }, @@ -678,6 +682,9 @@ pub struct ListScheduleQuery { pub summary: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] @@ -695,6 +702,15 @@ pub struct ScheduleLight { pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the schedules page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -703,6 +719,7 @@ pub struct ScheduleLight { async fn list_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lsq): Query, ) -> JsonResult> { @@ -724,13 +741,20 @@ async fn list_schedule( "labels", "folder_labels(workspace_id, path) as inherited_labels", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = schedule.workspace_id \ + AND draft.path = schedule.path AND draft.typ = 'trigger_schedule' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&w_id)) .offset(offset) .limit(per_page) .clone(); - if let Some(path) = lsq.path { - sqlb.and_where_eq("script_path", "?".bind(&path)); + if let Some(path) = lsq.path.as_ref() { + sqlb.and_where_eq("script_path", "?".bind(path)); } if let Some(is_flow) = lsq.is_flow { sqlb.and_where_eq("is_flow", "?".bind(&is_flow)); @@ -773,10 +797,96 @@ async fn list_schedule( } } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; - let rows = sqlx::query_as::<_, ScheduleLight>(&sql) + let mut rows = sqlx::query_as::<_, ScheduleLight>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + + // Append the authed user's draft-only schedules; see scripts.rs. + if lsq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lsq.path.is_none() + && lsq.is_flow.is_none() + && lsq.args.is_none() + && lsq.path_start.is_none() + && lsq.schedule_path.is_none() + && lsq.description.is_none() + && lsq.summary.is_none() + && lsq.broad_filter.is_none() + && lsq.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + ) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // Schedule editor's draft mirrors NewSchedule: { path, schedule, timezone, script_path, is_flow, enabled?, summary?, labels? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() { + continue; + } + let schedule = v + .get("schedule") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let timezone = v + .get("timezone") + .and_then(|x| x.as_str()) + .unwrap_or("UTC") + .to_string(); + let script_path = v + .get("script_path") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let is_flow = v.get("is_flow").and_then(|x| x.as_bool()).unwrap_or(false); + let enabled = v.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + let summary = v + .get("summary") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + + rows.push(ScheduleLight { + workspace_id: w_id.clone(), + path, + edited_by: String::new(), + edited_at: row.created_at, + schedule, + timezone, + enabled, + script_path, + is_flow, + summary, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -839,16 +949,28 @@ async fn list_schedule_with_jobs( async fn get_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("schedules:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let schedule_o = windmill_queue::schedule::get_schedule_opt(&mut *tx, &w_id, path).await?; - let schedule = not_found_if_none(schedule_o, "Schedule", path)?; tx.commit().await?; - Ok(Json(schedule)) + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + path, + q.get_draft, + schedule_o, + || Error::NotFound(format!("Schedule not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn exists_schedule( @@ -1137,6 +1259,9 @@ async fn delete_schedule( tx.commit().await?; + // Schedule gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::TriggerSchedule, path).await?; + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index ce375fa64f..944329c316 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -13,6 +13,7 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::{ + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -33,7 +34,6 @@ use itertools::Itertools; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; use serde_json::json; -use serde_json::value::RawValue; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; use std::{collections::HashMap, sync::Arc}; @@ -45,7 +45,7 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_common::{ assets::{ clear_static_asset_usage, clear_static_asset_usage_by_script_hash, - insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType, + insert_static_asset_usage, AssetUsageKind, }, error::{self, to_anyhow}, min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, @@ -82,122 +82,6 @@ use windmill_queue::{ const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; -#[derive(Serialize, sqlx::FromRow)] -pub struct ScriptWDraft { - pub hash: ScriptHash, - pub path: String, - pub summary: String, - pub description: String, - pub content: String, - pub language: ScriptLang, - pub kind: ScriptKind, - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - pub schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub envs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ttl: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ignore_s3_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub restart_unless_cancelled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_use: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_secs: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_preprocessor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub assets: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub modules: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, - #[serde(flatten)] - #[sqlx(flatten)] - pub runnable_settings: SR, -} - -impl ScriptWDraft { - pub async fn prefetch_cached<'a>( - self, - db: &DB, - ) -> error::Result> { - let (debouncing_settings, concurrency_settings) = - windmill_common::runnable_settings::prefetch_cached_from_handle( - self.runnable_settings.runnable_settings_handle, - db, - ) - .await?; - - Ok(ScriptWDraft { - runnable_settings: ScriptRunnableSettingsInline { - concurrency_settings: concurrency_settings.maybe_fallback( - self.runnable_settings.concurrency_key, - self.runnable_settings.concurrent_limit, - self.runnable_settings.concurrency_time_window_s, - ), - debouncing_settings: debouncing_settings.maybe_fallback( - self.runnable_settings.debounce_key, - self.runnable_settings.debounce_delay_s, - ), - }, - hash: self.hash, - path: self.path, - summary: self.summary, - description: self.description, - content: self.content, - language: self.language, - kind: self.kind, - tag: self.tag, - draft: self.draft, - draft_created_at: self.draft_created_at, - schema: self.schema, - draft_only: self.draft_only, - envs: self.envs, - cache_ttl: self.cache_ttl, - cache_ignore_s3_path: self.cache_ignore_s3_path, - dedicated_worker: self.dedicated_worker, - ws_error_handler_muted: self.ws_error_handler_muted, - priority: self.priority, - restart_unless_cancelled: self.restart_unless_cancelled, - delete_after_use: self.delete_after_use, - delete_after_secs: self.delete_after_secs, - timeout: self.timeout, - visible_to_runner_only: self.visible_to_runner_only, - auto_kind: self.auto_kind, - has_preprocessor: self.has_preprocessor, - on_behalf_of_email: self.on_behalf_of_email, - assets: self.assets, - modules: self.modules, - labels: self.labels, - }) - } -} - pub fn global_service() -> Router { Router::new() .route("/hub/top", get(get_top_hub_scripts)) @@ -222,7 +106,6 @@ pub fn workspaced_service() -> Router { .route("/create", post(create_script)) .route("/create_snapshot", post(create_snapshot_script)) .route("/archive/p/{*path}", post(archive_script_by_path)) - .route("/get/draft/{*path}", get(get_script_by_path_w_draft)) .route("/get/p/{*path}", get(get_script_by_path)) .route("/list_tokens/{*path}", get(list_tokens)) .route("/raw/p/{*path}", get(raw_script_by_path)) @@ -295,6 +178,7 @@ async fn list_search_scripts( async fn list_scripts( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -305,7 +189,7 @@ async fn list_scripts( "hash", "o.path", "summary", - "COALESCE(draft.created_at, o.created_at) as created_at", + "o.created_at as created_at", "archived", "extra_perms", if !lq.without_description.unwrap_or(false) { @@ -317,13 +201,22 @@ async fn list_scripts( "language", "favorite.path IS NOT NULL as starred", "tag", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "auto_kind", "codebase IS NOT NULL as use_codebase", "kind", "o.labels", + "draft.email IS NOT NULL as is_draft", + // Canonical reference for the draft-feature comments; flows/apps point here. + // Per-path draft owners as a JSON array (`Json>`); NULL -> None, + // never an empty array. LEFT JOIN `usr` keeps orphaned drafts (user left workspace) + // visible with `username = None`. In the `admins` workspace username IS the email, + // so fall back to `d.email` there or the authed user's own draft resolves to a phantom + // "Legacy workspace draft"; the genuine NULL-email legacy row stays None. + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() @@ -335,7 +228,8 @@ async fn list_scripts( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?" + .bind(&authed.email), ) .order_desc("favorite.path IS NOT NULL") .order_by("created_at", lq.order_desc.unwrap_or(true)) @@ -361,10 +255,6 @@ async fn list_scripts( sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("draft_only IS NOT TRUE"); - } - if lq.show_archived.unwrap_or(false) { sqlb.and_where_eq( "o.ctid", @@ -434,7 +324,7 @@ async fn list_scripts( .fields(&["dm.deployment_msg"]); } - if let Some(languages) = lq.languages { + if let Some(languages) = &lq.languages { sqlb.and_where_in( "language", &languages @@ -447,13 +337,114 @@ async fn list_scripts( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "scripts", "read"); - let rows = sqlx::query_as::<_, ListableScript>(&sql) + let mut rows = sqlx::query_as::<_, ListableScript>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Canonical reference for draft-only synthesis; the other kinds point here. + // Append the authed user's drafts at paths with no deployed script. Gated on + // `include_draft_only` so picker callers stay deployed-only (home page opts in); + // skipped past page 0 or under any narrowing filter to keep pagination clean. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.created_by.is_none() + && lq.first_parent_hash.is_none() + && lq.last_parent_hash.is_none() + && lq.parent_hash.is_none() + && lq.is_template.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && lq.languages.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` surfaces the user's own draft-only rows plus + // legacy NULL-email workspace rows; `DISTINCT ON (path)` ordered `email IS NULL` + // last collapses a path holding both to the owned row. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'script' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM script s + WHERE s.workspace_id = draft.workspace_id + AND s.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + let language: ScriptLang = v + .get("language") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + let kind: ScriptKind = v + .get("kind") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or(ScriptKind::Script); + // Scripts bind the Path widget to `script.path`, so the typed path + // round-trips through the draft JSON's own `path` (no `draft_path` field). + let draft_path = v + .get("path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableScript { + hash: ScriptHash(0), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + created_at: row.created_at, + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + language, + starred: false, + tag: v.get("tag").and_then(|s| s.as_str()).map(|s| s.to_string()), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + draft_only: Some(true), + has_deploy_errors: false, + ws_error_handler_muted: None, + auto_kind: None, + use_codebase: false, + deployment_msg: None, + kind, + labels: None, + // Synthesized rows have no deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft (single-user case). + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -721,7 +712,6 @@ async fn is_noop_deploy_against_parent( language, kind, tag, - draft_only, envs, concurrency_settings, debouncing_settings, @@ -798,7 +788,6 @@ async fn is_noop_deploy_against_parent( || visible_to_runner_only != &parent.visible_to_runner_only || has_preprocessor != &parent.has_preprocessor || is_template.unwrap_or(false) != parent.is_template.unwrap_or(false) - || draft_only.unwrap_or(false) != parent.draft_only.unwrap_or(false) { return Ok(false); } @@ -1026,20 +1015,10 @@ async fn create_script_internal<'c>( let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), - (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( + (None, Some(s)) => Err(Error::BadRequest(format!( "Path conflict for {} with non-archived hash {}", &ns.path, &s.hash ))), - (None, Some(s)) => { - sqlx::query!( - "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - s.hash.0, - &w_id - ) - .execute(&mut *tx) - .await?; - Ok(None) - } (Some(p_hash), o) => { // Lock the parent row to prevent concurrent updates with the same parent_hash // This ensures linear lineage - only one script can have a given parent at a time @@ -1292,10 +1271,10 @@ async fn create_script_internal<'c>( sqlx::query!( "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", &w_id, &hash.0, ns.path, @@ -1311,7 +1290,6 @@ async fn create_script_internal<'c>( lang as ScriptLang, ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind, ns.tag, - ns.draft_only, envs, guarded_concurrent_limit, guarded_concurrency_time_window_s, @@ -1376,10 +1354,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { if !skip_draft_deletion { + // Canonical: on deploy only wipe the deployer's own draft (plus the legacy + // NULL-email row). Teammates' drafts are independent — they stay and fire the + // StaleDraftModal on the teammate's next reload rather than vanishing silently. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", p_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1463,10 +1446,14 @@ async fn create_script_internal<'c>( } } } else if !skip_draft_deletion { + // See the matching branch above — only wipe the deployer's own + // draft (plus the legacy NULL-email row). sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", ns.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1773,14 +1760,25 @@ pub async fn pick_hub_script_by_path( Ok::<_, Error>((status_code, headers, response)) } +// Canonical: fields inlined rather than `#[serde(flatten)]` from +// `WithStarredInfoQuery` / `WithDraftQuery`. axum's `serde_urlencoded` extractor +// drops type info through flatten, so `?get_draft=true` arrives as a String and +// fails the inner bool deserializer. Inlining lets the bool adapter see it directly. +#[derive(Deserialize)] +struct GetScriptByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + #[axum::debug_handler] async fn get_script_by_path( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult> { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1800,7 +1798,7 @@ async fn get_script_by_path( ORDER BY s.created_at DESC LIMIT 1", ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1812,19 +1810,33 @@ async fn get_script_by_path( ), ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let script = windmill_common::scripts::prefetch_cached_script_with_starred( - not_found_if_none(script_o, "Script", path)?, + // Canonical: with no deployed row and `get_draft` set, fall back to the draft + // table so editing a never-deployed draft works like a deployed reload. + let deployed = match script_o { + Some(script_o) => Some( + windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db).await?, + ), + None => None, + }; + let overlay = overlay_or_draft_only( &db, + &w_id, + &authed.email, + UserDraftItemKind::Script, + path, + query.get_draft, + deployed, + || windmill_common::error::Error::NotFound(format!("Script not found at path {path}")), ) .await?; - Ok(Json(script)) + Ok(Json(overlay)) } async fn list_tokens( @@ -1835,32 +1847,6 @@ async fn list_tokens( list_tokens_internal(&db, &w_id, &path, false).await } -async fn get_script_by_path_w_draft( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON - script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' - WHERE script.path = $1 AND script.workspace_id = $2 - ORDER BY script.created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - let script = not_found_if_none(script_o, "Script", path)?; - Ok(Json(script.prefetch_cached(&db).await?)) -} - async fn get_script_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -2521,18 +2507,6 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", - "script", - ) - .await; -} - async fn archive_script_by_path( authed: ApiAuthed, Extension(webhook): Extension, @@ -2769,18 +2743,7 @@ async fn delete_script_by_path( let mut tx = user_db.begin(&authed).await?; - let draft_only = sqlx::query_scalar!( - "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - if !draft_only { - require_admin(authed.is_admin, &authed.username)?; - } + require_admin(authed.is_admin, &authed.username)?; // Capture all script versions and drafts for trashbin before deleting let trash_scripts: Vec = sqlx::query_scalar( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index e0037861d3..d809cc1343 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3844,10 +3844,15 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } +// `authed_email` is the forker's email — `clone_drafts` only carries this +// user's per-user drafts (and the legacy NULL-email workspace draft, if any) +// across, since other users aren't added to the fork's `usr` table and +// their drafts would dangle as orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed_email: &str, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -3888,6 +3893,14 @@ async fn clone_workspace_data( // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + // Clone the forker's own per-user drafts (plus the legacy NULL-email + // workspace draft, if any) so they keep their pending edits in the + // fork. Other users' drafts are intentionally NOT cloned — they don't + // own a `usr` row in the fork (see `clone_workspace_full`) so their + // drafts would dangle and the home-page `draft_users` aggregate would + // surface them as duplicate legacy entries. + clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -4364,7 +4377,7 @@ async fn clone_scripts( r#"INSERT INTO script ( workspace_id, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4374,7 +4387,7 @@ async fn clone_scripts( SELECT $1, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4417,12 +4430,12 @@ async fn clone_flows( sqlx::query!( "INSERT INTO flow ( workspace_id, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, dependency_job, draft_only, tag, + archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, versions, on_behalf_of_email, lock_error_logs ) SELECT $2, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, NULL, draft_only, tag, + archived, schema, extra_perms, NULL, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs FROM flow @@ -4503,7 +4516,7 @@ async fn clone_apps( ) -> Result> { // Get all apps from source workspace let apps = sqlx::query!( - "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path + "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path FROM app WHERE workspace_id = $1", source_workspace_id @@ -4516,8 +4529,8 @@ async fn clone_apps( // Clone apps with new IDs for app in apps { let new_app_id = sqlx::query_scalar!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", target_workspace_id, app.path, @@ -4525,7 +4538,6 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.draft_only, app.custom_path, ) .fetch_one(&mut **tx) @@ -4729,6 +4741,39 @@ async fn clone_raw_apps( Ok(()) } +/// Clone every per-user draft (and the legacy NULL-email workspace draft, +/// if present) from the parent. The fork target is empty at create time so +/// a plain INSERT is safe — no need to UPSERT against the partial unique +/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the +/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list +/// it in the column set. `created_at` is preserved so the per-tab +/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's +/// `draft_saved_at`) lines up with the parent's timeline — otherwise the +/// fork's first POST from any open editor would race a stale `last_sync` +/// and trip the conflict modal on every cloned draft. +// Only `email = authed_email` and the legacy NULL row are cloned — see +// `clone_workspace_data` for the rationale. +async fn clone_drafts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + authed_email: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + SELECT $2, path, typ, value, created_at, email + FROM draft + WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + source_workspace_id, + target_workspace_id, + authed_email, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn clone_workspace_runnable_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -5023,7 +5068,7 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id, &authed.email).await?; // Clone triggers and schedules unconditionally, always with mode='disabled' / // enabled=false. Disabled rows have no side effects (no listener @@ -6972,7 +7017,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", source_workspace_id, path ) @@ -6984,7 +7029,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", fork_workspace_id, path ) @@ -7430,7 +7475,7 @@ async fn get_cloud_quotas( let scripts_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", &w_id @@ -7525,7 +7570,7 @@ async fn prune_versions( "DELETE FROM script WHERE workspace_id = $1 AND hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", ) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 55473a8861..45cac3a37d 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -279,8 +279,8 @@ pub(crate) async fn change_workspace_id( info!("Duplicating flow table rows"); sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs FROM flow WHERE workspace_id = $2", &rw.new_id, &old_id diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 00727e626f..0cf2e9aca0 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "version": "1.713.1", + "version": "1.723.0", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -7879,6 +7879,54 @@ } } }, + "/users/tokens/update_label/{token_prefix}": { + "post": { + "summary": "update label of an existing token (owner only)", + "operationId": "updateTokenLabel", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "token_prefix", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "new label (null or omitted = no label)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "label updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users/tokens/list": { "get": { "summary": "list token", @@ -8202,6 +8250,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -8210,7 +8261,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListableVariable" + "allOf": [ + { + "$ref": "#/components/schemas/ListableVariable" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -8350,6 +8408,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft variables whose path has no\ndeployed variable. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -10264,6 +10331,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -10272,7 +10342,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Resource" + "allOf": [ + { + "$ref": "#/components/schemas/ListableResource" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -10507,6 +10584,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -12005,7 +12091,37 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "type": "object", + "properties": { + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nscript — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Surfaced for draft-only rows so\nthe home list can render the meaningful name\ninstead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + } + } + } + ] } } } @@ -12043,10 +12159,10 @@ } } }, - "/w/{workspace}/drafts/create": { - "post": { - "summary": "create draft", - "operationId": "createDraft", + "/w/{workspace}/drafts/list": { + "get": { + "summary": "list every draft the current user has in this workspace, across all kinds", + "operationId": "listDrafts", "tags": [ "draft" ], @@ -12055,42 +12171,42 @@ "$ref": "#/components/parameters/WorkspaceId" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "typ": { - "type": "string", - "enum": [ - "flow", - "script", - "app" - ] - }, - "value": {} - }, - "required": [ - "path", - "typ", - "enum" - ] - } - } - } - }, "responses": { - "201": { - "description": "draft created", + "200": { + "description": "the user's drafts", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/UserDraftItemKind" + }, + "path": { + "type": "string" + }, + "summary": { + "type": "string", + "description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one." + }, + "draft_only": { + "type": "boolean", + "description": "No deployed counterpart exists at this path — the draft is the whole item." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "kind", + "path", + "draft_only", + "created_at" + ] + } } } } @@ -12098,10 +12214,10 @@ } } }, - "/w/{workspace}/drafts/delete/{kind}/{path}": { - "delete": { - "summary": "delete draft", - "operationId": "deleteDraft", + "/w/{workspace}/drafts/get/{kind}/{path}": { + "get": { + "summary": "fetch a single draft's content by workspace username (or the legacy workspace-level row)", + "operationId": "getDraftForUser", "tags": [ "draft" ], @@ -12114,25 +12230,122 @@ "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "script", - "flow", - "app" - ] + "$ref": "#/components/schemas/UserDraftItemKind" + } + }, + { + "$ref": "#/components/parameters/ScriptPath" + }, + { + "name": "username", + "in": "query", + "required": false, + "description": "Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "draft content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {}, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "value", + "created_at" + ] + } + } + } + }, + "404": { + "description": "no draft for that owner at that path" + } + } + } + }, + "/w/{workspace}/drafts/update/{kind}/{path}": { + "post": { + "summary": "upsert (or clear) the current user's draft at a path", + "operationId": "updateDraft", + "tags": [ + "draft" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserDraftItemKind" } }, { "$ref": "#/components/parameters/ScriptPath" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules." + }, + "last_sync": { + "type": "string", + "format": "date-time", + "description": "Server timestamp of the client's last known sync for this draft. Omit on first save." + }, + "force": { + "type": "boolean", + "description": "Skip the conflict check and overwrite the server copy." + } + } + } + } + } + }, "responses": { "200": { - "description": "draft deleted", + "description": "save result", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "saved", + "conflict" + ] + }, + "current_timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status", + "current_timestamp" + ] } } } @@ -12729,6 +12942,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -12737,7 +12953,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -12806,35 +13029,6 @@ } } }, - "/w/{workspace}/scripts/get/draft/{path}": { - "get": { - "summary": "get script by path with draft", - "operationId": "getScriptByPathWithDraft", - "tags": [ - "script" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "script details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewScriptWithDraft" - } - } - } - } - } - } - }, "/w/{workspace}/scripts/history/p/{path}": { "get": { "summary": "get history of a script by path", @@ -14541,6 +14735,42 @@ } } }, + "/w/{workspace}/jobs/job_view_token/{id}": { + "get": { + "summary": "mint a read-only share token for a job", + "description": "Returns a stateless `{job_id}.{hmac}` token that grants an authenticated workspace member read access to this job (and its flow subtree) via a `view_token` query param or `X-View-Token` header. Only callable by a user who can already read the job.\n", + "operationId": "getJobViewToken", + "tags": [ + "job" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "the share read token", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/w/{workspace}/flows/list_paths": { "get": { "summary": "list all flow paths", @@ -14722,11 +14952,29 @@ { "type": "object", "properties": { - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nflow — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } } } } @@ -14950,6 +15198,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -14958,7 +15209,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Flow" + "allOf": [ + { + "$ref": "#/components/schemas/Flow" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -15110,52 +15368,6 @@ } } }, - "/w/{workspace}/flows/get/draft/{path}": { - "get": { - "summary": "get flow by path with draft", - "operationId": "getFlowByPathWithDraft", - "tags": [ - "flow" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "flow details with draft", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Flow" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/Flow" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - } - } - } - } - } - } - }, "/w/{workspace}/flows/exists/{path}": { "get": { "summary": "exists flow by path", @@ -15224,9 +15436,6 @@ { "type": "object", "properties": { - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -15643,6 +15852,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -16059,9 +16271,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16140,9 +16349,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16245,6 +16451,17 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" + }, + { + "name": "raw_app", + "in": "query", + "description": "When no deployed app exists at this path and `get_draft` is set,\ndisambiguates which draft kind (`raw_app` or `app`) to look up.\nIgnored when a deployed row exists.\n", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -16253,7 +16470,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppWithLastVersion" + "allOf": [ + { + "$ref": "#/components/schemas/AppWithLastVersion" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -16290,35 +16514,6 @@ } } }, - "/w/{workspace}/apps/get/draft/{path}": { - "get": { - "summary": "get app by path with draft", - "operationId": "getAppByPathWithDraft", - "tags": [ - "app" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "app details with draft", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppWithLastVersionWDraft" - } - } - } - } - } - } - }, "/w/{workspace}/apps/history/p/{path}": { "get": { "summary": "get app history by path", @@ -18545,6 +18740,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -18962,6 +19171,20 @@ { "$ref": "#/components/parameters/Success" }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "$ref": "#/components/parameters/JobKinds" }, @@ -19345,6 +19568,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -21488,6 +21725,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21496,7 +21736,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "allOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -21618,6 +21865,15 @@ "type": "string" }, "description": "Filter by label" + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft schedules whose path has\nno deployed schedule. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -21966,6 +22222,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21974,7 +22233,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HttpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/HttpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22030,6 +22296,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22303,6 +22572,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22311,7 +22583,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebsocketTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/WebsocketTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22367,6 +22646,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22638,6 +22920,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22646,7 +22931,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KafkaTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/KafkaTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22702,6 +22994,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23039,6 +23334,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23047,7 +23345,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NatsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/NatsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23103,6 +23408,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23367,6 +23675,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23375,7 +23686,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SqsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/SqsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23431,6 +23749,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24145,6 +24466,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24620,6 +24944,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24628,7 +24955,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MqttTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/MqttTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -24684,6 +25018,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24948,6 +25285,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24956,7 +25296,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/GcpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25012,6 +25359,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -25389,6 +25739,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -25397,7 +25750,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AzureTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/AzureTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25443,6 +25803,9 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26302,6 +26665,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26310,7 +26676,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostgresTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/PostgresTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26366,6 +26739,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26630,6 +27006,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26638,7 +27017,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/EmailTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26694,6 +27080,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -27667,6 +28056,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -27729,6 +28124,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -31494,6 +31895,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -32604,6 +33019,24 @@ } }, "parameters": { + "GetDraft": { + "name": "get_draft", + "in": "query", + "required": false, + "description": "When true, overlay the authed user's draft (if any) onto the deployed payload.", + "schema": { + "type": "boolean" + } + }, + "IncludeDraftOnly": { + "name": "include_draft_only", + "in": "query", + "required": false, + "description": "When true, append per-user draft rows whose path has no\ndeployed counterpart. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge. Gated to\nnon-operators + page 0 + no narrowing filters on the backend so\npicker callers stay deployed-only and pagination stays clean.\n", + "schema": { + "type": "boolean" + } + }, "Id": { "name": "id", "in": "path", @@ -33148,6 +33581,73 @@ } }, "schemas": { + "UserDraftOverlay": { + "type": "object", + "description": "Overlay fields added to every \"get by path\" response that accepts\nthe `get_draft` query parameter. The deployed payload is sent\nuntouched in the response body; the authed user's saved draft\nfor this path — whatever shape the editor wrote — is attached\nas the sibling `draft` field when `get_draft=true` and a draft\nexists. The frontend pairs the two to present diff / reset /\ndiscard UI; the server never merges them.\n\nWhen `no_deployed=true` there is no deployed row at this path —\nthe response body is a best-effort stand-in synthesized from\nthe draft, and only `draft` is canonical. Callers should disable\n\"diff vs deployed\" UI in that case.\n", + "properties": { + "is_draft": { + "type": "boolean" + }, + "draft_saved_at": { + "type": "string", + "format": "date-time" + }, + "no_deployed": { + "type": "boolean" + }, + "draft": { + "type": "object", + "additionalProperties": true + }, + "other_drafts_users": { + "description": "Other workspace users (and the legacy NULL-email row, if any)\nwith a saved draft at the same path. Populated only on the\nauthed user's \"get by path\" responses for kinds the editor\nsurfaces a fork banner for (script, flow, app, raw_app).\nEmpty / omitted for kinds without that UI.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true, + "description": "Workspace username of the draft owner. `null` represents\nthe legacy workspace-level (NULL-email) row. Emails never\nleave the server.\n" + } + } + } + } + }, + "required": [ + "is_draft" + ] + }, + "UserDraftItemKind": { + "type": "string", + "description": "Closed set of item kinds a user can autosave as a draft. Mirrors the\nPostgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.\n", + "enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + }, "OpenFlow": { "type": "object", "description": "Top-level flow definition containing metadata, configuration, and the flow structure", @@ -33354,6 +33854,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ @@ -34174,6 +34678,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -35367,6 +35875,9 @@ "items": { "type": "string" } + }, + "web_search_enabled": { + "type": "boolean" } }, "required": [ @@ -35633,9 +36144,6 @@ "tag": { "type": "string" }, - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" }, @@ -35723,6 +36231,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -35787,9 +36302,6 @@ "tag": { "type": "string" }, - "draft_only": { - "type": "boolean" - }, "envs": { "type": "array", "items": { @@ -35930,32 +36442,6 @@ "language" ] }, - "NewScriptWithDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/NewScript" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/NewScript" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - }, - "hash": { - "type": "string" - } - }, - "required": [ - "hash" - ] - } - ] - }, "ScriptHistory": { "type": "object", "properties": { @@ -37391,6 +37877,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" }, @@ -37400,6 +37893,14 @@ }, "edited_by": { "type": "string" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nvariable at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38115,6 +38616,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" } @@ -38178,8 +38686,23 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nresource at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38384,6 +38907,21 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nschedule at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -38785,6 +39323,14 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\ntrigger at the same path. Set by list endpoints when\n`include_draft_only=true` synthesizes the row from the\ndraft. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -41871,6 +42417,13 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels set on the folder. Items inside the folder inherit them, exposed as `inherited_labels` on scripts and flows and stamped into job labels at run time.\n" } }, "required": [ @@ -42369,6 +42922,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42599,6 +43159,34 @@ "type": "string" }, "default": [] + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this app — either no\ndeployed row exists at this path (draft-only) or the user has\nsaved a per-user draft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42691,6 +43279,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42780,27 +43375,6 @@ "raw_app" ] }, - "AppWithLastVersionWDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/AppWithLastVersion" - }, - { - "type": "object", - "properties": { - "draft_only": { - "type": "boolean" - }, - "draft": {}, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - }, "AppHistory": { "type": "object", "properties": { @@ -44350,7 +44924,8 @@ "enum": [ "DisableDirectDeployment", "DisableWorkspaceForking", - "RestrictDeployToDeployers" + "RestrictDeployToDeployers", + "RestrictAnonymousAppDeployment" ] }, "RuleBypasserGroups": { @@ -46030,6 +46605,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -46062,6 +46641,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 4ced5ef265..22792b38e3 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.713.1 + version: 1.723.0 title: Windmill API contact: name: Windmill Team @@ -146,18 +146,18 @@ paths: checks: type: object description: Detailed health checks - required: &ref_347 + required: &ref_352 - database - readiness - properties: &ref_348 + properties: &ref_353 database: type: object description: Database health status - required: &ref_349 + required: &ref_354 - healthy - latency_ms - pool - properties: &ref_350 + properties: &ref_355 healthy: type: boolean description: Whether the database is reachable @@ -168,11 +168,11 @@ paths: pool: type: object description: Database connection pool statistics - required: &ref_351 + required: &ref_356 - size - idle - max_connections - properties: &ref_352 + properties: &ref_357 size: type: integer description: Current number of connections in the pool @@ -186,13 +186,13 @@ paths: description: Workers health status nullable: true type: object - required: &ref_353 + required: &ref_358 - healthy - active_count - worker_groups - min_version - versions - properties: &ref_354 + properties: &ref_359 healthy: type: boolean description: Whether any workers are active @@ -219,10 +219,10 @@ paths: description: Job queue status nullable: true type: object - required: &ref_355 + required: &ref_360 - pending_jobs - running_jobs - properties: &ref_356 + properties: &ref_361 pending_jobs: type: integer format: int64 @@ -234,9 +234,9 @@ paths: readiness: type: object description: Server readiness status - required: &ref_357 + required: &ref_362 - healthy - properties: &ref_358 + properties: &ref_363 healthy: type: boolean description: Whether the server is ready to accept requests @@ -337,7 +337,7 @@ paths: - name: id in: path required: true - schema: &ref_79 + schema: &ref_84 type: integer responses: '200': @@ -488,24 +488,24 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: &ref_299 + schema: &ref_304 type: string format: date-time - name: after description: filter on created after (exclusive) timestamp in: query - schema: &ref_300 + schema: &ref_305 type: string format: date-time - name: username description: filter on exact username of user in: query - schema: &ref_308 + schema: &ref_313 type: string - name: operation description: filter on exact or prefix name of operation in: query - schema: &ref_309 + schema: &ref_314 type: string - name: operations in: query @@ -520,12 +520,12 @@ paths: - name: resource description: filter on exact or prefix name of resource in: query - schema: &ref_310 + schema: &ref_315 type: string - name: action_kind description: filter on type of operation in: query - schema: &ref_311 + schema: &ref_316 type: string enum: - Create @@ -562,12 +562,12 @@ paths: application/json: schema: type: object - properties: &ref_397 + properties: &ref_403 email: type: string password: type: string - required: &ref_398 + required: &ref_404 - email - password responses: @@ -757,7 +757,7 @@ paths: nullable: true allOf: - type: object - properties: &ref_394 + properties: &ref_400 source: type: string enum: @@ -775,7 +775,7 @@ paths: description: >- The instance group name (when source is 'instance_group') - required: &ref_395 + required: &ref_401 - source is_service_account: type: boolean @@ -813,7 +813,7 @@ paths: application/json: schema: type: object - properties: &ref_399 + properties: &ref_405 is_admin: type: boolean operator: @@ -1187,7 +1187,7 @@ paths: type: array items: type: object - properties: &ref_413 + properties: &ref_419 jwt_hash: type: integer format: int64 @@ -1210,7 +1210,7 @@ paths: last_used_at: type: string format: date-time - required: &ref_414 + required: &ref_420 - jwt_hash - email - username @@ -1342,7 +1342,7 @@ paths: type: array items: type: object - properties: &ref_400 + properties: &ref_406 label: type: string scopes: @@ -1351,7 +1351,7 @@ paths: type: string expiration: type: string - required: &ref_401 + required: &ref_407 - label - scopes description: Tokens owned by this user (will be deleted) @@ -1395,7 +1395,7 @@ paths: application/json: schema: type: object - properties: &ref_402 + properties: &ref_408 reassign_to: type: string description: 'Target for reassignment: ''u/{username}'' or ''f/{folder}''' @@ -1409,7 +1409,7 @@ paths: type: boolean default: true description: Whether to also remove the user from the workspace - required: &ref_403 + required: &ref_409 - reassign_to responses: '200': @@ -1428,7 +1428,7 @@ paths: on success. summary: type: object - properties: &ref_404 + properties: &ref_410 scripts_reassigned: type: integer flows_reassigned: @@ -1445,7 +1445,7 @@ paths: type: integer drafts_deleted: type: integer - required: &ref_405 + required: &ref_411 - scripts_reassigned - flows_reassigned - apps_reassigned @@ -1475,12 +1475,12 @@ paths: application/json: schema: type: object - properties: &ref_406 + properties: &ref_412 workspaces: type: array items: type: object - properties: &ref_408 + properties: &ref_414 workspace_id: type: string username: @@ -1489,11 +1489,11 @@ paths: type: object properties: *ref_12 required: *ref_13 - required: &ref_409 + required: &ref_415 - workspace_id - username - preview - required: &ref_407 + required: &ref_413 - workspaces /users/offboard/{email}: post: @@ -1515,12 +1515,12 @@ paths: application/json: schema: type: object - properties: &ref_410 + properties: &ref_416 reassignments: type: object additionalProperties: type: object - properties: &ref_411 + properties: &ref_417 reassign_to: type: string description: 'Target: ''u/{username}'' or ''f/{folder}''' @@ -1529,7 +1529,7 @@ paths: description: >- Required when reassign_to is a folder. Username to use as permissioned_as. - required: &ref_412 + required: &ref_418 - reassign_to description: Map of workspace_id to reassignment config delete_user: @@ -1589,7 +1589,7 @@ paths: application/json: schema: type: array - items: &ref_562 + items: &ref_563 type: object properties: workspace_id: @@ -1699,7 +1699,7 @@ paths: application/json: schema: type: object - properties: &ref_502 + properties: &ref_504 email: type: string workspaces: @@ -1774,7 +1774,7 @@ paths: - username - color - disabled - required: &ref_503 + required: &ref_505 - email - workspaces /w/{workspace}/workspaces/get_as_superadmin: @@ -1836,7 +1836,7 @@ paths: application/json: schema: type: object - properties: &ref_504 + properties: &ref_506 id: type: string name: @@ -1845,7 +1845,7 @@ paths: type: string color: type: string - required: &ref_505 + required: &ref_507 - id - name responses: @@ -2021,7 +2021,7 @@ paths: properties: &ref_24 logs: type: object - properties: &ref_470 + properties: &ref_472 super_admin: type: string enum: &ref_21 @@ -2828,11 +2828,11 @@ paths: type: array items: type: object - properties: &ref_545 + properties: &ref_546 name: type: string value: {} - required: &ref_546 + required: &ref_547 - name - value /settings/instance_config: @@ -2930,9 +2930,9 @@ paths: application/json: schema: type: object - required: &ref_370 + required: &ref_375 - keys - properties: &ref_371 + properties: &ref_376 keys: type: array items: @@ -3049,11 +3049,11 @@ paths: type: array items: type: object - required: &ref_368 + required: &ref_373 - workspace_id - path - error - properties: &ref_369 + properties: &ref_374 workspace_id: type: string description: Workspace ID where the secret is located @@ -4348,13 +4348,13 @@ paths: application/json: schema: type: object - required: &ref_554 + required: &ref_555 - all_ahead_items_visible - all_behind_items_visible - skipped_comparison - diffs - summary - properties: &ref_555 + properties: &ref_556 all_ahead_items_visible: type: boolean description: >- @@ -4375,7 +4375,7 @@ paths: description: List of differences found between workspaces items: type: object - required: &ref_556 + required: &ref_557 - kind - path - ahead @@ -4383,7 +4383,7 @@ paths: - has_changes - exists_in_source - exists_in_fork - properties: &ref_557 + properties: &ref_558 kind: type: string enum: @@ -4428,7 +4428,7 @@ paths: summary: description: Summary statistics of the comparison type: object - required: &ref_558 + required: &ref_559 - total_diffs - total_ahead - total_behind @@ -4442,7 +4442,7 @@ paths: - schedules_changed - triggers_changed - conflicts - properties: &ref_559 + properties: &ref_560 total_diffs: type: integer description: Total number of items with differences @@ -4639,12 +4639,12 @@ paths: type: array items: type: object - properties: &ref_531 + properties: &ref_532 pattern: type: string allow: type: string - required: &ref_532 + required: &ref_533 - pattern - allow secondary_storage: @@ -4777,7 +4777,7 @@ paths: auto_invite: type: object description: Configuration for auto-inviting users to the workspace - properties: &ref_359 + properties: &ref_364 enabled: type: boolean default: false @@ -4818,14 +4818,16 @@ paths: type: object additionalProperties: type: object - properties: &ref_378 + properties: &ref_383 resource_path: type: string models: type: array items: type: string - required: &ref_379 + web_search_enabled: + type: boolean + required: &ref_384 - resource_path - models default_model: @@ -4871,7 +4873,7 @@ paths: error_handler: type: object description: Configuration for the workspace error handler - properties: &ref_360 + properties: &ref_365 path: type: string description: Path to the error handler script or flow @@ -4888,7 +4890,7 @@ paths: success_handler: type: object description: Configuration for the workspace success handler - properties: &ref_361 + properties: &ref_366 path: type: string description: Path to the success handler script or flow @@ -5192,7 +5194,7 @@ paths: type: array items: type: object - properties: &ref_507 + properties: &ref_509 importer_path: type: string importer_kind: @@ -5206,7 +5208,7 @@ paths: items: type: string nullable: true - required: &ref_508 + required: &ref_510 - importer_path - importer_kind /w/{workspace}/workspaces/get_imports/{importer_path}: @@ -5264,13 +5266,13 @@ paths: type: array items: type: object - properties: &ref_509 + properties: &ref_511 imported_path: type: string count: type: integer format: int64 - required: &ref_510 + required: &ref_512 - imported_path - count /w/{workspace}/workspaces/get_dependency_map: @@ -5293,7 +5295,7 @@ paths: type: array items: type: object - properties: &ref_506 + properties: &ref_508 workspace_id: type: string nullable: true @@ -5825,7 +5827,7 @@ paths: type: array items: type: object - properties: &ref_380 + properties: &ref_385 provider: type: string enum: *ref_51 @@ -5833,7 +5835,7 @@ paths: type: array items: type: string - required: &ref_381 + required: &ref_386 - provider - models default_model: @@ -5924,10 +5926,10 @@ paths: Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_362 + oneOf: &ref_367 - type: object description: New grouped format for editing error handler - properties: &ref_363 + properties: &ref_368 path: type: string description: Path to the error handler script or flow @@ -5945,7 +5947,7 @@ paths: description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: &ref_364 + properties: &ref_369 error_handler: type: string description: Path to the error handler script or flow @@ -5984,10 +5986,10 @@ paths: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_365 + oneOf: &ref_370 - type: object description: New grouped format for editing success handler - properties: &ref_366 + properties: &ref_371 path: type: string description: Path to the success handler script or flow @@ -5999,7 +6001,7 @@ paths: description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: &ref_367 + properties: &ref_372 success_handler: type: string description: Path to the success handler script or flow @@ -6116,10 +6118,10 @@ paths: type: array items: type: object - required: &ref_523 + required: &ref_524 - datatable_name - schemas - properties: &ref_524 + properties: &ref_525 datatable_name: type: string schemas: @@ -6161,10 +6163,10 @@ paths: type: array items: type: object - required: &ref_525 + required: &ref_526 - datatable_name - schemas - properties: &ref_526 + properties: &ref_527 datatable_name: type: string schemas: @@ -6209,12 +6211,12 @@ paths: application/json: schema: type: object - required: &ref_527 + required: &ref_528 - datatable_name - schema_name - table_name - columns - properties: &ref_528 + properties: &ref_529 datatable_name: type: string schema_name: @@ -6975,7 +6977,7 @@ paths: type: array items: type: object - properties: &ref_396 + properties: &ref_402 email: type: string executions: @@ -7038,7 +7040,7 @@ paths: type: array items: type: object - properties: &ref_518 + properties: &ref_520 name: type: string description: @@ -7048,7 +7050,7 @@ paths: type: array items: type: object - properties: &ref_516 + properties: &ref_518 value: type: string label: @@ -7058,11 +7060,11 @@ paths: nullable: true requires_resource_path: type: boolean - required: &ref_517 + required: &ref_519 - value - label - requires_resource_path - required: &ref_519 + required: &ref_521 - name - scopes /users/tokens/create: @@ -7078,7 +7080,7 @@ paths: application/json: schema: type: object - properties: &ref_415 + properties: &ref_421 label: type: string expiration: @@ -7119,7 +7121,7 @@ paths: application/json: schema: type: object - properties: &ref_416 + properties: &ref_422 label: type: string expiration: @@ -7129,7 +7131,7 @@ paths: type: string workspace_id: type: string - required: &ref_417 + required: &ref_423 - impersonate_email responses: '201': @@ -7189,6 +7191,36 @@ paths: text/plain: schema: type: string + /users/tokens/update_label/{token_prefix}: + post: + summary: update label of an existing token (owner only) + operationId: updateTokenLabel + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new label (null or omitted = no label) + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + nullable: true + responses: + '200': + description: label updated + content: + text/plain: + schema: + type: string /users/tokens/list: get: summary: list token @@ -7217,7 +7249,7 @@ paths: type: array items: type: object - properties: &ref_102 + properties: &ref_108 label: type: string expiration: @@ -7241,7 +7273,7 @@ paths: type: string read_only: type: boolean - required: &ref_103 + required: &ref_109 - token_prefix - created_at - last_used_at @@ -7297,7 +7329,7 @@ paths: application/json: schema: type: object - properties: &ref_420 + properties: &ref_426 path: type: string description: The path to the variable @@ -7326,7 +7358,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_421 + required: &ref_427 - path - value - is_secret @@ -7448,7 +7480,7 @@ paths: application/json: schema: type: object - properties: &ref_422 + properties: &ref_428 path: type: string description: The path to the variable @@ -7504,59 +7536,168 @@ paths: in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: &ref_77 + type: boolean responses: '200': description: variable content: application/json: schema: - type: object - properties: &ref_61 - workspace_id: - type: string - path: - type: string - value: - type: string - is_secret: - type: boolean - description: - type: string - account: - type: integer - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - expires_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - edited_at: - type: string - format: date-time - edited_by: - type: string - required: &ref_62 - - workspace_id - - path - - is_secret - - extra_perms + allOf: + - type: object + properties: &ref_61 + workspace_id: + type: string + path: + type: string + value: + type: string + is_secret: + type: boolean + description: + type: string + account: + type: integer + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + expires_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + variable at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_62 + - workspace_id + - path + - is_secret + - extra_perms + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: &ref_78 + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: > + Other workspace users (and the legacy NULL-email row, + if any) + + with a saved draft at the same path. Populated only on + the + + authed user's "get by path" responses for kinds the + editor + + surfaces a fork banner for (script, flow, app, + raw_app). + + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: > + Workspace username of the draft owner. `null` + represents + + the legacy workspace-level (NULL-email) row. + Emails never + + leave the server. + required: &ref_79 + - is_draft /w/{workspace}/variables/get_value/{path}: get: summary: get variable value @@ -7662,6 +7803,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: '200': description: variable list @@ -7693,7 +7843,7 @@ paths: type: array items: type: object - properties: &ref_418 + properties: &ref_424 name: type: string value: @@ -7702,7 +7852,7 @@ paths: type: string is_custom: type: boolean - required: &ref_419 + required: &ref_425 - name - value - description @@ -7889,12 +8039,12 @@ paths: description: >- A workspace protection rule defining restrictions and bypass permissions - required: &ref_565 + required: &ref_566 - name - rules - bypass_groups - bypass_users - properties: &ref_566 + properties: &ref_567 name: type: string description: Unique name for the protection rule @@ -7906,10 +8056,11 @@ paths: description: Configuration of protection restrictions items: &ref_64 type: string - enum: &ref_567 + enum: &ref_568 - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers + - RestrictAnonymousAppDeployment bypass_groups: type: array description: Groups that can bypass this ruleset @@ -8066,11 +8217,11 @@ paths: type: array items: type: object - required: &ref_568 + required: &ref_569 - username - email - is_admin - properties: &ref_569 + properties: &ref_570 username: type: string email: @@ -8125,10 +8276,10 @@ paths: type: array items: type: object - required: &ref_570 + required: &ref_571 - username - email - properties: &ref_571 + properties: &ref_572 username: type: string email: @@ -9051,7 +9202,7 @@ paths: application/json: schema: type: object - properties: &ref_427 + properties: &ref_433 path: type: string description: The path to the resource @@ -9068,7 +9219,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_428 + required: &ref_434 - path - value - resource_type @@ -9159,7 +9310,7 @@ paths: application/json: schema: type: object - properties: &ref_429 + properties: &ref_435 path: type: string description: The path to the resource @@ -9230,44 +9381,123 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: resource content: application/json: schema: - type: object - properties: &ref_430 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_431 - - path - - resource_type - - is_oauth + allOf: + - type: object + properties: &ref_80 + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + account: + type: number + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + resource at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_81 + - path + - resource_type + - is_oauth + - is_linked + - is_refreshed + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/resources/get_value_interpolated/{path}: get: summary: get resource interpolated (variables and resources are fully unrolled) @@ -9438,6 +9668,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: resource list @@ -9447,49 +9686,8 @@ paths: type: array items: type: object - properties: &ref_432 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - account: - type: number - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_433 - - path - - resource_type - - is_oauth - - is_linked - - is_refreshed + properties: *ref_80 + required: *ref_81 /w/{workspace}/resources/list_search: get: summary: list resources for search @@ -9565,7 +9763,7 @@ paths: - name: name in: path required: true - schema: &ref_273 + schema: &ref_278 type: string responses: '200': @@ -9602,7 +9800,7 @@ paths: application/json: schema: type: object - properties: &ref_77 + properties: &ref_82 workspace_id: type: string name: @@ -9619,7 +9817,7 @@ paths: type: string is_fileset: type: boolean - required: &ref_78 + required: &ref_83 - name responses: '201': @@ -9698,7 +9896,7 @@ paths: application/json: schema: type: object - properties: &ref_434 + properties: &ref_436 schema: {} description: type: string @@ -9733,8 +9931,8 @@ paths: application/json: schema: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/exists/{path}: get: summary: does resource_type exists @@ -9778,8 +9976,8 @@ paths: type: array items: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/listnames: get: summary: list resource_types names @@ -10061,7 +10259,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: flow @@ -10075,7 +10273,7 @@ paths: description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: &ref_120 + properties: &ref_124 summary: type: string description: Short description of what this flow does @@ -10087,7 +10285,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_610 + properties: &ref_611 modules: type: array description: >- @@ -10098,7 +10296,7 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: &ref_82 + properties: &ref_87 id: type: string description: >- @@ -10112,14 +10310,14 @@ paths: Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: &ref_88 + oneOf: &ref_93 - type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: &ref_323 + properties: &ref_328 input_transforms: type: object description: >- @@ -10133,14 +10331,14 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_80 + oneOf: &ref_85 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: &ref_136 + properties: &ref_140 value: description: >- The static value. For resources, use @@ -10149,7 +10347,7 @@ paths: type: string enum: - static - required: &ref_137 + required: &ref_141 - type - type: object description: >- @@ -10159,7 +10357,7 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: &ref_84 + properties: &ref_89 expr: type: string description: >- @@ -10172,7 +10370,7 @@ paths: type: string enum: - javascript - required: &ref_85 + required: &ref_90 - expr - type - type: object @@ -10180,14 +10378,14 @@ paths: Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: &ref_86 + properties: &ref_91 type: type: string enum: - ai - required: &ref_87 + required: &ref_92 - type - discriminator: &ref_81 + discriminator: &ref_86 propertyName: type mapping: static: >- @@ -10297,7 +10495,7 @@ paths: - r - w - rw - required: &ref_324 + required: &ref_329 - type - content - language @@ -10307,7 +10505,7 @@ paths: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: &ref_325 + properties: &ref_330 input_transforms: type: object description: >- @@ -10321,8 +10519,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10347,7 +10545,7 @@ paths: description: >- If true, this script is a trigger that can start the flow - required: &ref_326 + required: &ref_331 - type - path - input_transforms @@ -10356,7 +10554,7 @@ paths: Reference to an existing flow by path. Use this to call another flow as a subflow - properties: &ref_327 + properties: &ref_332 input_transforms: type: object description: >- @@ -10370,8 +10568,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10381,7 +10579,7 @@ paths: type: string enum: - flow - required: &ref_328 + required: &ref_333 - type - path - input_transforms @@ -10394,7 +10592,7 @@ paths: 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: &ref_329 + properties: &ref_334 modules: type: array description: >- @@ -10406,8 +10604,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: &ref_83 + properties: *ref_87 + required: &ref_88 - value - id iterator: @@ -10416,8 +10614,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -10439,11 +10637,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_330 + required: &ref_335 - modules - iterator - skip_failures @@ -10455,7 +10653,7 @@ paths: condition after each iteration. Use stop_after_if on modules to control loop termination - properties: &ref_331 + properties: &ref_336 modules: type: array description: >- @@ -10467,8 +10665,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -10489,11 +10687,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_332 + required: &ref_337 - modules - skip_failures - type @@ -10505,7 +10703,7 @@ paths: one with a true expression runs. If no branches match, the default branch executes - properties: &ref_333 + properties: &ref_338 branches: type: array description: >- @@ -10536,8 +10734,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -10551,13 +10749,13 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: - branchone - required: &ref_334 + required: &ref_339 - branches - default - type @@ -10568,7 +10766,7 @@ paths: BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: &ref_335 + properties: &ref_340 branches: type: array description: >- @@ -10595,8 +10793,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -10609,7 +10807,7 @@ paths: If true, all branches execute concurrently. If false, they execute sequentially - required: &ref_336 + required: &ref_341 - branches - type - type: object @@ -10617,7 +10815,7 @@ paths: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: &ref_337 + properties: &ref_342 type: type: string enum: @@ -10627,7 +10825,7 @@ paths: description: >- If true, marks this as a flow identity (special handling) - required: &ref_338 + required: &ref_343 - type - type: object description: >- @@ -10635,7 +10833,7 @@ paths: accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: &ref_339 + properties: &ref_344 input_transforms: type: object description: >- @@ -10647,22 +10845,22 @@ paths: Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: &ref_341 + oneOf: &ref_346 - type: object description: >- Static provider configuration passed directly to the AI agent - properties: &ref_595 + properties: &ref_596 value: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: &ref_593 + properties: &ref_594 kind: type: string description: Supported AI provider types - enum: &ref_316 + enum: &ref_321 - openai - azure_openai - anthropic @@ -10685,7 +10883,7 @@ paths: description: >- Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro') - required: &ref_594 + required: &ref_595 - kind - resource - model @@ -10693,7 +10891,7 @@ paths: type: string enum: - static - required: &ref_596 + required: &ref_597 - type - value - type: object @@ -10704,16 +10902,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_342 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_347 propertyName: type mapping: static: >- @@ -10728,8 +10926,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -10743,8 +10941,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation @@ -10756,8 +10954,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. @@ -10769,8 +10967,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -10783,27 +10981,27 @@ paths: Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: &ref_343 + oneOf: &ref_348 - type: object description: >- Static memory configuration passed directly to the AI agent - properties: &ref_601 + properties: &ref_602 value: description: Conversation memory configuration - oneOf: &ref_599 + oneOf: &ref_600 - type: object description: No conversation memory/context - properties: &ref_317 + properties: &ref_322 kind: type: string enum: - 'off' - required: &ref_318 + required: &ref_323 - kind - type: object description: Automatic context management - properties: &ref_319 + properties: &ref_324 kind: type: string enum: @@ -10818,11 +11016,11 @@ paths: description: >- Identifier for persistent memory across agent invocations - required: &ref_320 + required: &ref_325 - kind - type: object description: Explicit message history - properties: &ref_321 + properties: &ref_326 kind: type: string enum: @@ -10832,7 +11030,7 @@ paths: items: type: object description: A single message in conversation history - properties: &ref_597 + properties: &ref_598 role: type: string enum: @@ -10841,13 +11039,13 @@ paths: - system content: type: string - required: &ref_598 + required: &ref_599 - role - content - required: &ref_322 + required: &ref_327 - kind - messages - discriminator: &ref_600 + discriminator: &ref_601 propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' @@ -10857,7 +11055,7 @@ paths: type: string enum: - static - required: &ref_602 + required: &ref_603 - type - value - type: object @@ -10868,16 +11066,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_344 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_349 propertyName: type mapping: static: >- @@ -10892,8 +11090,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI @@ -10914,8 +11112,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -10932,8 +11130,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -10947,8 +11145,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Float. Controls randomness/creativity of responses. @@ -10967,8 +11165,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -10990,7 +11188,7 @@ paths: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: &ref_345 + properties: &ref_350 id: type: string description: >- @@ -11008,12 +11206,12 @@ paths: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: &ref_608 + oneOf: &ref_609 - description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: &ref_603 + allOf: &ref_604 - type: object properties: tool_type: @@ -11027,8 +11225,8 @@ paths: step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: &ref_89 + oneOf: *ref_93 + discriminator: &ref_94 propertyName: type mapping: rawscript: '#/components/schemas/schemas-RawScript' @@ -11046,7 +11244,7 @@ paths: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: &ref_604 + properties: &ref_605 tool_type: type: string enum: @@ -11070,7 +11268,7 @@ paths: MCP server items: type: string - required: &ref_605 + required: &ref_606 - tool_type - resource_path - type: object @@ -11078,26 +11276,32 @@ paths: A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: &ref_606 + properties: &ref_607 tool_type: type: string enum: - websearch - required: &ref_607 + required: &ref_608 - tool_type - discriminator: &ref_609 + discriminator: &ref_610 propertyName: tool_type mapping: flowmodule: '#/components/schemas/FlowModuleTool' mcp: '#/components/schemas/McpToolValue' websearch: '#/components/schemas/WebsearchToolValue' - required: &ref_346 + required: &ref_351 - id - value type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. + If not set, the AI agent step runs on + the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -11111,15 +11315,15 @@ paths: description: >- If true, the agent can execute multiple tool calls in parallel - required: &ref_340 + required: &ref_345 - tools - type - input_transforms - discriminator: *ref_89 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: &ref_90 + properties: &ref_95 skip_if_stopped: type: boolean description: >- @@ -11143,13 +11347,22 @@ paths: If empty string, a default error message is used. If null or omitted, no error is raised. - required: &ref_91 + error_include_result: + type: boolean + description: >- + When stopping with an error + (error_message set), embed the stopping + step's own result inside the raised + error object (as error.result) instead + of discarding it. The top-level result + stays { error }. Defaults to false. + required: &ref_96 - expr stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -11169,8 +11382,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: >- @@ -11183,8 +11396,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -11243,8 +11456,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: >- @@ -11275,7 +11488,7 @@ paths: Retry configuration for failed module executions type: object - properties: &ref_315 + properties: &ref_320 constant: type: object description: >- @@ -11316,14 +11529,14 @@ paths: description: >- Conditional retry based on error or result - properties: &ref_195 + properties: &ref_199 expr: type: string description: >- JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables - required: &ref_196 + required: &ref_200 - expr debouncing: description: >- @@ -11360,21 +11573,21 @@ paths: description: >- Maximum number of debounces before forced execution - required: *ref_83 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -11464,7 +11677,7 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: &ref_145 + properties: &ref_149 id: type: string description: Unique identifier for the note @@ -11523,7 +11736,7 @@ paths: description: >- For group notes, the IDs of nodes contained within this group - required: &ref_146 + required: &ref_150 - id - text - color @@ -11543,7 +11756,7 @@ paths: collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: &ref_147 + properties: &ref_151 summary: type: string description: Display name for this group @@ -11569,10 +11782,10 @@ paths: color: type: string description: Color for the group in the flow editor - required: &ref_148 + required: &ref_152 - start_id - end_id - required: &ref_611 + required: &ref_612 - modules schema: type: object @@ -11586,7 +11799,7 @@ paths: description: >- The flow will be run with the permissions of the user with this email. - required: &ref_121 + required: &ref_125 - summary - value /apps/hub/list: @@ -11639,7 +11852,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app @@ -11669,7 +11882,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: raw app @@ -11699,7 +11912,7 @@ paths: - name: custom_path in: path required: true - schema: &ref_133 + schema: &ref_137 type: string responses: '200': @@ -11709,7 +11922,7 @@ paths: schema: allOf: - type: object - properties: &ref_128 + properties: &ref_132 id: type: integer workspace_id: @@ -11730,7 +11943,7 @@ paths: value: {} policy: type: object - properties: &ref_127 + properties: &ref_131 triggerables: type: object additionalProperties: @@ -11783,7 +11996,7 @@ paths: items: type: string default: [] - required: &ref_129 + required: &ref_133 - id - workspace_id - path @@ -11810,7 +12023,7 @@ paths: - name: path in: path required: true - schema: &ref_92 + schema: &ref_97 type: string responses: '200': @@ -11829,7 +12042,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script details @@ -11860,7 +12073,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script pick recorded @@ -11923,7 +12136,7 @@ paths: type: number kind: type: string - enum: &ref_93 + enum: &ref_98 - script - failure - trigger @@ -11994,7 +12207,7 @@ paths: type: string kind: type: string - enum: *ref_93 + enum: *ref_98 score: type: number required: @@ -12056,7 +12269,7 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: &ref_118 + schema: &ref_122 type: boolean - name: created_by description: >- @@ -12064,7 +12277,7 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: &ref_119 + schema: &ref_123 type: string - name: path_start description: mask to filter matching starting path @@ -12204,183 +12417,225 @@ paths: schema: type: array items: - type: object - properties: &ref_99 - workspace_id: - type: string - hash: - type: string - path: - type: string - parent_hashes: - type: array - description: > - The first element is the direct parent of the script, - the second is the parent of the first, etc - items: - type: string - summary: - type: string - description: - type: string - content: - type: string - created_by: - type: string - created_at: - type: string - format: date-time - archived: - type: boolean - schema: - type: object - deleted: - type: boolean - is_template: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - lock: - type: string - lock_error_logs: - type: string - language: - type: string - enum: &ref_94 - - python3 - - deno - - go - - bash - - powershell - - postgresql - - mysql - - bigquery - - snowflake - - mssql - - oracledb - - graphql - - nativets - - bun - - php - - rust - - ansible - - csharp - - nu - - java - - ruby - - rlang - - duckdb - - bunnative - kind: - type: string - enum: - - script - - failure - - trigger - - command - - approval - - preprocessor - starred: - type: boolean - tag: - type: string - has_draft: - type: boolean - draft_only: - type: boolean - envs: - type: array - items: - type: string - concurrent_limit: - type: integer - concurrency_time_window_s: - type: integer - concurrency_key: - type: string - debounce_key: - type: string - debounce_delay_s: - type: integer - debounce_args_to_accumulate: - type: array - items: - type: string - max_total_debouncing_time: - type: integer - max_total_debounces_amount: - type: integer - cache_ttl: - type: number - dedicated_worker: - type: boolean - ws_error_handler_muted: - type: boolean - priority: - type: integer - restart_unless_cancelled: - type: boolean - timeout: - type: integer - delete_after_secs: - type: integer - description: >- - If set, delete the job's args, result and logs after - this many seconds following job completion - visible_to_runner_only: - type: boolean - auto_kind: - type: string - codebase: - type: string - has_preprocessor: - type: boolean - on_behalf_of_email: - type: string - modules: - type: object - nullable: true - description: Additional script modules keyed by relative file path - additionalProperties: - type: object - description: An additional module file associated with a script - properties: &ref_95 - content: + allOf: + - type: object + properties: &ref_105 + workspace_id: + type: string + hash: + type: string + path: + type: string + parent_hashes: + type: array + description: > + The first element is the direct parent of the + script, the second is the parent of the first, etc + items: type: string - description: The source code content of this module - language: + summary: + type: string + description: + type: string + content: + type: string + created_by: + type: string + created_at: + type: string + format: date-time + archived: + type: boolean + schema: + type: object + deleted: + type: boolean + is_template: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + lock: + type: string + lock_error_logs: + type: string + language: + type: string + enum: &ref_99 + - python3 + - deno + - go + - bash + - powershell + - postgresql + - mysql + - bigquery + - snowflake + - mssql + - oracledb + - graphql + - nativets + - bun + - php + - rust + - ansible + - csharp + - nu + - java + - ruby + - rlang + - duckdb + - bunnative + kind: + type: string + enum: + - script + - failure + - trigger + - command + - approval + - preprocessor + starred: + type: boolean + tag: + type: string + draft_only: + type: boolean + envs: + type: array + items: type: string - enum: *ref_94 - lock: + concurrent_limit: + type: integer + concurrency_time_window_s: + type: integer + concurrency_key: + type: string + debounce_key: + type: string + debounce_delay_s: + type: integer + debounce_args_to_accumulate: + type: array + items: type: string - nullable: true - description: Lock file content for this module's dependencies - required: &ref_96 - - content - - language - labels: - type: array - items: - type: string - default: [] - required: &ref_100 - - hash - - path - - summary - - description - - content - - created_by - - created_at - - archived - - deleted - - is_template - - extra_perms - - language - - kind - - starred - - has_preprocessor + max_total_debouncing_time: + type: integer + max_total_debounces_amount: + type: integer + cache_ttl: + type: number + dedicated_worker: + type: boolean + ws_error_handler_muted: + type: boolean + priority: + type: integer + restart_unless_cancelled: + type: boolean + timeout: + type: integer + delete_after_secs: + type: integer + description: >- + If set, delete the job's args, result and logs after + this many seconds following job completion + visible_to_runner_only: + type: boolean + auto_kind: + type: string + codebase: + type: string + has_preprocessor: + type: boolean + on_behalf_of_email: + type: string + modules: + type: object + nullable: true + description: >- + Additional script modules keyed by relative file + path + additionalProperties: + type: object + description: An additional module file associated with a script + properties: &ref_101 + content: + type: string + description: The source code content of this module + language: + type: string + enum: *ref_99 + lock: + type: string + nullable: true + description: >- + Lock file content for this module's + dependencies + required: &ref_102 + - content + - language + labels: + type: array + items: + type: string + default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_106 + - hash + - path + - summary + - description + - content + - created_by + - created_at + - archived + - deleted + - is_template + - extra_perms + - language + - kind + - starred + - has_preprocessor + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: summary: list all scripts paths @@ -12401,10 +12656,12 @@ paths: type: array items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: >- + list every draft the current user has in this workspace, across all + kinds + operationId: listDrafts tags: - draft parameters: @@ -12412,37 +12669,75 @@ paths: in: path required: true schema: *ref_4 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: - - flow - - script - - app - value: {} - required: - - path - - typ - - enum responses: - '201': - description: draft created + '200': + description: the user's drafts content: - text/plain: + application/json: schema: - type: string - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + type: array + items: + type: object + properties: + kind: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. + Mirrors the + + Postgres `DRAFT_KIND` enum and the backend + `UserDraftItemKind`. + enum: &ref_100 + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github + path: + type: string + summary: + type: string + description: >- + Best-effort, read from the draft JSON's `summary` field + when the editor shape carries one. + draft_only: + type: boolean + description: >- + No deployed counterpart exists at this path — the draft + is the whole item. + created_at: + type: string + format: date-time + required: + - kind + - path + - draft_only + - created_at + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: >- + fetch a single draft's content by workspace username (or the legacy + workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -12455,21 +12750,107 @@ paths: required: true schema: type: string - enum: - - script - - flow - - app + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 + - name: username + in: query + required: false + description: >- + Workspace username of the draft owner. Omit to fetch the legacy + workspace-level (NULL email) row. + schema: + type: string responses: '200': - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: + - value + - created_at + '404': + description: no draft for that owner at that path + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: kind + in: path + required: true + schema: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 + - name: path + in: path + required: true + schema: *ref_97 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: >- + Draft content to save. `null` (or omitted) signals a delete + — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: >- + Server timestamp of the client's last known sync for this + draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + '200': + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - saved + - conflict + current_timestamp: + type: string + format: date-time + required: + - status + - current_timestamp /w/{workspace}/scripts/create: post: summary: create script @@ -12507,7 +12888,7 @@ paths: application/json: schema: type: object - properties: &ref_104 + properties: &ref_392 path: type: string parent_hash: @@ -12526,7 +12907,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 kind: type: string enum: @@ -12538,8 +12919,6 @@ paths: - preprocessor tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -12611,7 +12990,7 @@ paths: type: string kind: type: string - enum: &ref_301 + enum: &ref_306 - s3object - resource - ducklake @@ -12636,8 +13015,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 labels: type: array items: @@ -12647,7 +13026,7 @@ paths: description: >- When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path. - required: &ref_105 + required: &ref_393 - path - summary - content @@ -12673,7 +13052,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -12780,19 +13159,19 @@ paths: application/json: schema: type: object - properties: &ref_385 + properties: &ref_390 workspace_id: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: type: string description: type: string content: type: string - required: &ref_386 + required: &ref_391 - workspace_id - language - content @@ -12819,7 +13198,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12847,7 +13226,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12879,7 +13258,7 @@ paths: type: array items: type: object - properties: &ref_97 + properties: &ref_103 id: type: integer archived: @@ -12892,13 +13271,13 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_id: type: string created_at: type: string format: date-time - required: &ref_98 + required: &ref_104 - workspace_id - language - created_at @@ -12921,7 +13300,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12934,8 +13313,8 @@ paths: application/json: schema: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 /w/{workspace}/scripts/archive/p/{path}: post: summary: archive script by path @@ -12950,7 +13329,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script archived @@ -12972,7 +13351,7 @@ paths: - name: hash in: path required: true - schema: &ref_101 + schema: &ref_107 type: string responses: '200': @@ -12981,8 +13360,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/h/{hash}: post: summary: delete script by hash (erase content but keep hash, require admin) @@ -12998,7 +13377,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13006,8 +13385,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/p/{path}: post: summary: delete script at a given path (require admin) @@ -13023,7 +13402,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -13085,20 +13464,63 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: script details content: application/json: schema: - type: object - properties: *ref_99 - required: *ref_100 + allOf: + - type: object + properties: *ref_105 + required: *ref_106 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/scripts/get_triggers_count/{path}: get: summary: get triggers count of script @@ -13113,7 +13535,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -13121,7 +13543,7 @@ paths: application/json: schema: type: object - properties: &ref_125 + properties: &ref_129 primary_schedule: type: object properties: @@ -13173,7 +13595,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -13183,50 +13605,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: script details - content: - application/json: - schema: - allOf: &ref_387 - - type: object - properties: *ref_104 - required: *ref_105 - - type: object - properties: - draft: - type: object - properties: *ref_104 - required: *ref_105 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. - hash: - type: string - required: - - hash + properties: *ref_108 + required: *ref_109 /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -13241,7 +13621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script history @@ -13251,12 +13631,12 @@ paths: type: array items: type: object - properties: &ref_106 + properties: &ref_110 script_hash: type: string deployment_msg: type: string - required: &ref_107 + required: &ref_111 - script_hash /w/{workspace}/scripts/list_paths_from_workspace_runnable/{path}: get: @@ -13272,7 +13652,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of script paths @@ -13294,7 +13674,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - script responses: @@ -13304,8 +13684,8 @@ paths: application/json: schema: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 /w/{workspace}/scripts/history_update/h/{hash}/p/{path}: post: summary: update history of a script @@ -13320,11 +13700,11 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Script deployment message required: true @@ -13412,7 +13792,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13436,12 +13816,12 @@ paths: - name: token in: path required: true - schema: &ref_305 + schema: &ref_310 type: string - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13463,7 +13843,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: does it exists @@ -13485,7 +13865,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: with_starred_info in: query schema: @@ -13501,8 +13881,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/raw/h/{path}: get: summary: raw script by hash @@ -13517,7 +13897,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13539,7 +13919,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13589,7 +13969,7 @@ paths: type: array items: type: object - properties: &ref_108 + properties: &ref_112 test_script_path: type: string job_id: @@ -13603,7 +13983,7 @@ paths: type: string format: date-time nullable: true - required: &ref_109 + required: &ref_113 - test_script_path /w/{workspace}/scripts/ci_test_results_batch: post: @@ -13652,8 +14032,8 @@ paths: type: array items: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 /w/{workspace}/scripts/raw_temp/store: post: summary: store raw script content temporarily for CLI lock generation @@ -13720,7 +14100,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: description: named workspace dependency (null for default) type: string @@ -13817,7 +14197,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -13839,20 +14219,20 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: &ref_110 + schema: &ref_114 type: string format: uuid - name: tag description: Override the tag to use in: query - schema: &ref_111 + schema: &ref_115 type: string - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: &ref_112 + schema: &ref_116 type: string - name: job_id description: >- @@ -13861,7 +14241,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: &ref_113 + schema: &ref_117 type: string format: uuid - name: invisible_to_owner @@ -13900,23 +14280,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13924,7 +14304,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -13933,19 +14313,19 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: &ref_114 + schema: &ref_118 type: string - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: &ref_115 + schema: &ref_119 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: &ref_116 + schema: &ref_120 type: boolean requestBody: description: script args @@ -13975,23 +14355,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13999,7 +14379,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14008,13 +14388,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14022,12 +14402,12 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: &ref_117 + schema: &ref_121 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 responses: '200': description: job result @@ -14048,7 +14428,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14057,13 +14437,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14071,11 +14451,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14123,13 +14503,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14137,11 +14517,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14188,13 +14568,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14202,7 +14582,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14210,11 +14590,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14241,7 +14621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14250,13 +14630,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14264,11 +14644,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14310,7 +14690,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14319,13 +14699,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14333,7 +14713,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14341,11 +14721,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14391,13 +14771,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14405,11 +14785,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14463,13 +14843,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14477,7 +14857,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14485,11 +14865,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14523,23 +14903,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14547,7 +14927,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14556,17 +14936,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14602,23 +14982,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14626,7 +15006,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14635,13 +15015,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14649,11 +15029,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14688,17 +15068,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14706,7 +15086,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14715,17 +15095,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14768,17 +15148,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14786,7 +15166,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14795,13 +15175,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14809,11 +15189,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14854,6 +15234,35 @@ paths: content: application/json: schema: {} + /w/{workspace}/jobs/job_view_token/{id}: + get: + summary: mint a read-only share token for a job + description: > + Returns a stateless `{job_id}.{hmac}` token that grants an authenticated + workspace member read access to this job (and its flow subtree) via a + `view_token` query param or `X-View-Token` header. Only callable by a + user who can already read the job. + operationId: getJobViewToken + tags: + - job + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: the share read token + content: + text/plain: + schema: + type: string /w/{workspace}/flows/list_paths: get: summary: list all flow paths @@ -14924,14 +15333,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15006,15 +15415,15 @@ paths: type: array items: allOf: - - allOf: &ref_124 + - allOf: &ref_128 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object - properties: &ref_512 + properties: &ref_514 workspace_id: type: string path: @@ -15028,7 +15437,7 @@ paths: type: boolean extra_perms: type: object - additionalProperties: &ref_511 + additionalProperties: &ref_513 type: boolean starred: type: boolean @@ -15053,7 +15462,15 @@ paths: items: type: string default: [] - required: &ref_513 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, + computed at read time. Read-only — edit them on + the folder. + required: &ref_515 - path - edited_by - edited_at @@ -15067,10 +15484,40 @@ paths: type: number - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: summary: get flow history by path @@ -15083,7 +15530,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15095,7 +15542,7 @@ paths: type: array items: type: object - properties: &ref_122 + properties: &ref_126 id: type: integer created_at: @@ -15103,7 +15550,7 @@ paths: format: date-time deployment_msg: type: string - required: &ref_123 + required: &ref_127 - id - created_at /w/{workspace}/flows/get_latest_version/{path}: @@ -15118,7 +15565,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15128,8 +15575,8 @@ paths: application/json: schema: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 /w/{workspace}/flows/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list flow paths from workspace runnable @@ -15144,7 +15591,7 @@ paths: - name: runnable_kind in: path required: true - schema: &ref_132 + schema: &ref_136 type: string enum: - script @@ -15152,7 +15599,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: match_path_start in: query schema: @@ -15188,7 +15635,7 @@ paths: content: application/json: schema: - allOf: *ref_124 + allOf: *ref_128 /w/{workspace}/flows/history_update/v/{version}: post: summary: update flow history @@ -15239,18 +15686,61 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: flow details content: application/json: schema: - allOf: *ref_124 + allOf: + - allOf: *ref_128 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/flows/deployment_status/p/{path}: get: summary: get flow deployment status @@ -15265,7 +15755,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow status @@ -15293,7 +15783,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -15301,7 +15791,7 @@ paths: application/json: schema: type: object - properties: *ref_125 + properties: *ref_129 /w/{workspace}/flows/list_tokens/{path}: get: summary: get tokens with flow scope @@ -15316,7 +15806,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -15326,8 +15816,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 /w/{workspace}/flows/toggle_workspace_error_handler/{path}: post: summary: Toggle ON and OFF the workspace error handler for a given flow @@ -15342,7 +15832,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -15360,40 +15850,6 @@ paths: text/plain: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: flow details with draft - content: - application/json: - schema: - allOf: - - allOf: *ref_124 - - type: object - properties: - draft: - allOf: *ref_124 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -15408,7 +15864,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow details @@ -15446,13 +15902,13 @@ paths: application/json: schema: allOf: - - allOf: &ref_126 + - allOf: &ref_130 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object properties: path: @@ -15485,8 +15941,6 @@ paths: - path - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -15528,7 +15982,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Partially filled flow required: true @@ -15536,7 +15990,7 @@ paths: application/json: schema: allOf: - - allOf: *ref_126 + - allOf: *ref_130 - type: object properties: deployment_message: @@ -15568,7 +16022,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: archiveFlow required: true @@ -15601,7 +16055,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -15647,14 +16101,14 @@ paths: type: array items: type: object - required: &ref_372 + required: &ref_377 - id - workspace_id - flow_path - created_at - updated_at - created_by - properties: &ref_373 + properties: &ref_378 id: type: string format: uuid @@ -15747,14 +16201,14 @@ paths: type: array items: type: object - required: &ref_374 + required: &ref_379 - id - conversation_id - message_type - content - created_at - created_seq - properties: &ref_375 + properties: &ref_380 id: type: string format: uuid @@ -15860,14 +16314,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15891,6 +16345,17 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: &ref_212 + type: boolean responses: '200': description: All raw apps @@ -15900,7 +16365,7 @@ paths: type: array items: type: object - properties: &ref_520 + properties: &ref_522 workspace_id: type: string path: @@ -15923,7 +16388,14 @@ paths: items: type: string default: [] - required: &ref_521 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_523 - workspace_id - path - summary @@ -16135,14 +16607,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -16189,7 +16661,7 @@ paths: type: array items: type: object - properties: &ref_514 + properties: &ref_516 id: type: integer workspace_id: @@ -16222,7 +16694,61 @@ paths: items: type: string default: [] - required: &ref_515 + is_draft: + type: boolean + description: > + True when the authed user has a draft for this app — + either no + + deployed row exists at this path (draft-only) or the + user has + + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: > + User-typed path the editor has staged but not yet + deployed. + + Sourced from the draft JSON's `draft_path` field (the + editor + + only writes it when the typed path differs from the + deployed + + one). Lets the home list render the meaningful name + instead of + + the autogenerated `u/{user}/draft_{uuid}` URL path. + Omitted + + when unchanged. + draft_users: + description: > + Workspace users (including the authed user, and the + legacy + + NULL-email row if any) who have a per-user draft at this + + path. Drives the home page's user-avatar circles inside + the + + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_517 - id - workspace_id - path @@ -16267,9 +16793,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16330,9 +16854,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16406,20 +16928,71 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: '200': description: app details content: application/json: schema: - type: object - properties: *ref_128 - required: *ref_129 + allOf: + - type: object + properties: *ref_132 + required: *ref_133 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/apps/get/lite/{path}: get: summary: get app lite by path @@ -16434,7 +17007,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app lite details @@ -16442,45 +17015,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: app details with draft - content: - application/json: - schema: - allOf: &ref_522 - - type: object - properties: *ref_128 - required: *ref_129 - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -16495,7 +17031,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app history @@ -16505,12 +17041,12 @@ paths: type: array items: type: object - properties: &ref_130 + properties: &ref_134 version: type: integer deployment_msg: type: string - required: &ref_131 + required: &ref_135 - version /w/{workspace}/apps/get_latest_version/{path}: get: @@ -16524,7 +17060,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - app responses: @@ -16534,8 +17070,8 @@ paths: application/json: schema: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 /w/{workspace}/apps/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list app paths from workspace runnable @@ -16550,11 +17086,11 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of app paths @@ -16578,11 +17114,11 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 - name: version in: path required: true - schema: &ref_306 + schema: &ref_311 type: integer requestBody: description: App deployment message @@ -16623,8 +17159,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps_u/public_resource/{path}: get: summary: get public resource @@ -16704,7 +17240,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app details @@ -16712,8 +17248,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/delete/{path}: delete: summary: delete app @@ -16760,7 +17296,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16776,7 +17312,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16817,7 +17353,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16836,7 +17372,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16883,7 +17419,7 @@ paths: - name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 responses: '200': description: custom path exists @@ -16914,7 +17450,7 @@ paths: type: array items: type: object - properties: &ref_134 + properties: &ref_138 s3: type: string filename: @@ -16923,7 +17459,7 @@ paths: type: string presigned: type: string - required: &ref_135 + required: &ref_139 - s3 required: - s3_objects @@ -16936,8 +17472,8 @@ paths: type: array items: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 /w/{workspace}/apps_u/execute_component/{path}: post: summary: executeComponent @@ -16952,7 +17488,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -17137,7 +17673,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17152,17 +17688,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17170,7 +17706,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17179,7 +17715,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17240,17 +17776,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17258,7 +17794,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17267,7 +17803,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17335,14 +17871,14 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_138 + oneOf: &ref_142 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 - type: object description: >- JavaScript expression evaluated at runtime. Can @@ -17351,16 +17887,16 @@ paths: 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_139 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_143 propertyName: type mapping: static: '#/components/schemas/schemas-StaticTransform' @@ -17380,8 +17916,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 use_latest_version: type: boolean responses: @@ -17407,7 +17943,7 @@ paths: - name: id in: path required: true - schema: &ref_172 + schema: &ref_176 type: string format: uuid - name: scheduled_for @@ -17426,11 +17962,11 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17438,7 +17974,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17447,7 +17983,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17521,7 +18057,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17536,23 +18072,23 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17560,7 +18096,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17569,7 +18105,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17609,7 +18145,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17622,7 +18158,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 requestBody: description: preview required: true @@ -17630,7 +18166,7 @@ paths: application/json: schema: type: object - properties: &ref_141 + properties: &ref_145 content: type: string description: The code to run @@ -17646,7 +18182,7 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 + enum: *ref_99 tag: type: string kind: @@ -17668,8 +18204,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 temp_script_refs: type: object nullable: true @@ -17679,7 +18215,7 @@ paths: local content instead of the deployed script additionalProperties: type: string - required: &ref_142 + required: &ref_146 - args responses: '201': @@ -17707,7 +18243,7 @@ paths: application/json: schema: type: object - properties: &ref_423 + properties: &ref_429 content: type: string description: The code to run @@ -17717,8 +18253,8 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 - required: &ref_424 + enum: *ref_99 + required: &ref_430 - content - args - language @@ -17742,7 +18278,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: script args required: true @@ -17750,7 +18286,7 @@ paths: application/json: schema: type: object - properties: &ref_140 + properties: &ref_144 args: type: object description: The arguments to pass to the script or flow @@ -17775,7 +18311,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 requestBody: description: script args required: true @@ -17783,7 +18319,7 @@ paths: application/json: schema: type: object - properties: *ref_140 + properties: *ref_144 responses: '200': description: script result @@ -17817,8 +18353,8 @@ paths: application/json: schema: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 responses: '200': description: job result @@ -17853,12 +18389,12 @@ paths: application/json: schema: type: object - properties: &ref_425 + properties: &ref_431 args: type: object description: The arguments to pass to the script or flow additionalProperties: true - required: &ref_426 + required: &ref_432 - args responses: '201': @@ -17891,15 +18427,15 @@ paths: type: array items: type: object - properties: &ref_143 + properties: &ref_147 raw_code: type: string path: type: string language: type: string - enum: *ref_94 - required: &ref_144 + enum: *ref_99 + required: &ref_148 - raw_code - path - language @@ -17943,8 +18479,8 @@ paths: type: array items: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 entrypoint: type: string required: @@ -17984,7 +18520,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_149 + properties: &ref_153 modules: type: array description: >- @@ -17995,22 +18531,22 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -18093,8 +18629,8 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 groups: type: array description: Semantic groups of modules for organizational purposes @@ -18107,9 +18643,9 @@ paths: naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 - required: &ref_150 + properties: *ref_151 + required: *ref_152 + required: &ref_154 - modules required: - path @@ -18141,7 +18677,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -18154,7 +18690,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -18168,14 +18704,14 @@ paths: application/json: schema: type: object - properties: &ref_152 + properties: &ref_156 value: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 path: type: string args: @@ -18186,7 +18722,7 @@ paths: type: string restarted_from: type: object - properties: &ref_151 + properties: &ref_155 flow_job_id: type: string format: uuid @@ -18219,7 +18755,7 @@ paths: `RestartedFlow` against `nested.flow_job_id` instead of fresh-launching it. type: object - properties: *ref_151 + properties: *ref_155 temp_script_refs: type: object nullable: true @@ -18230,7 +18766,7 @@ paths: of the deployed script additionalProperties: type: string - required: &ref_153 + required: &ref_157 - value - content - args @@ -18266,8 +18802,8 @@ paths: application/json: schema: type: object - properties: *ref_152 - required: *ref_153 + properties: *ref_156 + required: *ref_157 responses: '200': description: job result @@ -18292,7 +18828,7 @@ paths: application/json: schema: type: object - properties: &ref_529 + properties: &ref_530 entrypoint_function: type: string description: Name of the function to execute for dynamic select @@ -18313,7 +18849,7 @@ paths: description: Path to the deployed script or flow runnable_kind: type: string - enum: &ref_200 + enum: &ref_204 - script - flow required: @@ -18331,11 +18867,11 @@ paths: description: Code content for inline execution language: type: string - enum: *ref_94 + enum: *ref_99 required: - source - code - required: &ref_530 + required: &ref_531 - entrypoint_function - runnable_ref responses: @@ -18361,27 +18897,27 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: &ref_156 + schema: &ref_160 type: string - name: script_path_exact description: >- @@ -18389,7 +18925,7 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: &ref_157 + schema: &ref_161 type: string - name: script_path_start description: >- @@ -18397,12 +18933,12 @@ paths: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: &ref_158 + schema: &ref_162 type: string - name: schedule_path description: mask to filter by schedule path in: query - schema: &ref_159 + schema: &ref_163 type: string - name: trigger_path description: >- @@ -18410,7 +18946,7 @@ paths: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: &ref_307 + schema: &ref_312 type: string - name: trigger_kind description: >- @@ -18419,34 +18955,34 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: &ref_188 + schema: &ref_192 type: string - name: script_hash description: mask to filter exact matching path in: query - schema: &ref_160 + schema: &ref_164 type: string - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_161 + schema: &ref_165 type: string format: date-time - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_162 + schema: &ref_166 type: string format: date-time - name: success description: filter on successful jobs in: query - schema: &ref_170 + schema: &ref_174 type: boolean - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: &ref_164 + schema: &ref_168 type: boolean - name: job_kinds description: >- @@ -18454,36 +18990,36 @@ paths: ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: &ref_165 + schema: &ref_169 type: string - name: suspended description: filter on suspended jobs in: query - schema: &ref_166 + schema: &ref_170 type: boolean - name: running description: filter on running jobs in: query - schema: &ref_163 + schema: &ref_167 type: boolean - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: &ref_167 + schema: &ref_171 type: string - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: &ref_169 + schema: &ref_173 type: string - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: &ref_171 + schema: &ref_175 type: boolean - name: tag description: >- @@ -18491,7 +19027,7 @@ paths: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: &ref_168 + schema: &ref_172 type: string - name: page description: which page to return (start at 1, default 1) @@ -18522,7 +19058,7 @@ paths: type: array items: type: object - properties: &ref_191 + properties: &ref_195 workspace_id: type: string id: @@ -18597,14 +19133,14 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: &ref_175 + properties: &ref_179 step: type: integer modules: type: array items: type: object - properties: &ref_154 + properties: &ref_158 type: type: string enum: @@ -18758,20 +19294,20 @@ paths: type: array items: type: boolean - required: &ref_155 + required: &ref_159 - type user_states: additionalProperties: true preprocessor_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 failure_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 - type: object properties: parent_module: @@ -18786,13 +19322,13 @@ paths: items: type: string format: uuid - required: &ref_176 + required: &ref_180 - step - modules - failure_module workflow_as_code_status: type: object - properties: &ref_177 + properties: &ref_181 scheduled_for: type: string format: date-time @@ -18808,13 +19344,13 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -18835,7 +19371,7 @@ paths: type: boolean worker: type: string - required: &ref_192 + required: &ref_196 - id - running - canceled @@ -18951,14 +19487,14 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: &ref_174 + schema: &ref_178 type: string - name: worker description: >- @@ -18966,117 +19502,117 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: &ref_182 + schema: &ref_186 type: string format: date-time - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: &ref_183 + schema: &ref_187 type: string format: date-time - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_184 + schema: &ref_188 type: string format: date-time - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_185 + schema: &ref_189 type: string format: date-time - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: &ref_186 + schema: &ref_190 type: string format: date-time - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: &ref_187 + schema: &ref_191 type: string format: date-time - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: page description: which page to return (start at 1, default 1) in: query @@ -19105,6 +19641,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -19140,96 +19689,96 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19315,7 +19864,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: list of OTEL Span objects (compatible with OpenTelemetry Span proto) @@ -19343,7 +19892,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: &ref_173 + enum: &ref_177 - webhook - default_email - email @@ -19409,7 +19958,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 - name: trigger_path description: The path of the trigger (can contain forward slashes) in: path @@ -19456,98 +20005,111 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19585,7 +20147,7 @@ paths: type: array items: type: object - properties: &ref_189 + properties: &ref_193 workspace_id: type: string id: @@ -19662,23 +20224,23 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 raw_flow: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19703,7 +20265,7 @@ paths: type: boolean worker: type: string - required: &ref_190 + required: &ref_194 - id - created_by - duration_ms @@ -19747,7 +20309,7 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: &ref_178 + properties: &ref_182 id: type: string format: uuid @@ -19842,7 +20404,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19885,7 +20447,7 @@ paths: status: type: string description: Actual job status from database - required: &ref_179 + required: &ref_183 - id - created_by - created_at @@ -19913,8 +20475,8 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 responses: '200': description: Successfully imported completed jobs @@ -19951,7 +20513,7 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: &ref_180 + properties: &ref_184 id: type: string format: uuid @@ -20041,7 +20603,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -20082,7 +20644,7 @@ paths: suspend_until: type: string format: date-time - required: &ref_181 + required: &ref_185 - id - created_by - created_at @@ -20110,8 +20672,8 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 responses: '200': description: Successfully imported queued jobs @@ -20166,123 +20728,123 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: per_page description: number of items to return for a given page (default 30, max 100) in: query @@ -20294,7 +20856,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -20315,6 +20877,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -20349,11 +20924,11 @@ paths: schema: type: array items: - oneOf: &ref_193 + oneOf: &ref_197 - allOf: - type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 - type: object properties: type: @@ -20362,15 +20937,15 @@ paths: - CompletedJob - allOf: - type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 - type: object properties: type: type: string enum: - QueuedJob - discriminator: &ref_194 + discriminator: &ref_198 propertyName: type /jobs/db_clock: get: @@ -20438,7 +21013,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: no_logs in: query schema: @@ -20461,8 +21036,8 @@ paths: content: application/json: schema: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 /w/{workspace}/jobs_u/get_root_job_id/{id}: get: summary: get root job id @@ -20477,7 +21052,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: get root job id @@ -20500,7 +21075,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: remove_ansi_warnings in: query schema: @@ -20526,7 +21101,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concatenated logs of all flow steps @@ -20548,7 +21123,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: completed job logs tail @@ -20570,7 +21145,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job args @@ -20621,7 +21196,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20668,11 +21243,11 @@ paths: type: string flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 /w/{workspace}/jobs_u/getupdate_sse/{id}: get: summary: get job updates via server-sent events @@ -20687,7 +21262,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20760,7 +21335,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: flow debug info details @@ -20781,7 +21356,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20789,8 +21364,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/completed/get_result/{id}: get: summary: get completed job result @@ -20805,7 +21380,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: suspended_job in: query schema: @@ -20842,10 +21417,10 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: get_started in: query - schema: &ref_313 + schema: &ref_318 type: boolean responses: '200': @@ -20879,7 +21454,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job timing details @@ -20912,7 +21487,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20920,8 +21495,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -20936,7 +21511,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21000,7 +21575,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21062,7 +21637,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: scheduled for timestamp @@ -21084,7 +21659,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21115,7 +21690,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21165,7 +21740,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21226,7 +21801,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21414,7 +21989,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -21422,7 +21997,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: resume_id in: path required: true @@ -21457,7 +22032,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21499,7 +22074,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21531,7 +22106,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21557,7 +22132,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: required: true content: @@ -21585,7 +22160,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21620,7 +22195,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21662,7 +22237,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21686,8 +22261,8 @@ paths: type: object properties: job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 approvers: type: array items: @@ -21762,7 +22337,7 @@ paths: application/json: schema: type: object - properties: &ref_436 + properties: &ref_438 path: type: string description: >- @@ -21855,7 +22430,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: &ref_197 + properties: &ref_201 constant: type: object description: Retry with constant delay between attempts @@ -21890,8 +22465,8 @@ paths: retry_if: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 no_flow_overlap: type: boolean description: >- @@ -21944,7 +22519,7 @@ paths: type: array items: type: string - required: &ref_437 + required: &ref_439 - path - schedule - timezone @@ -21988,7 +22563,7 @@ paths: application/json: schema: type: object - properties: &ref_438 + properties: &ref_440 schedule: type: string description: >- @@ -22063,7 +22638,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 no_flow_overlap: type: boolean description: >- @@ -22119,7 +22694,7 @@ paths: type: array items: type: string - required: &ref_439 + required: &ref_441 - schedule - timezone - args @@ -22208,189 +22783,257 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: schedule deleted content: application/json: schema: - type: object - properties: &ref_198 - path: - type: string - description: >- - The unique Windmill path for this schedule. Must be of the - form `u//` or `f//`. - edited_by: - type: string - description: Username of the last person who edited this schedule - edited_at: - type: string - format: date-time - description: Timestamp of the last edit - schedule: - type: string - description: >- - Cron expression with 6 fields (seconds, minutes, hours, - day of month, month, day of week). Example '0 0 12 * * *' - for daily at noon - timezone: - type: string - description: >- - IANA timezone for the schedule (e.g., 'UTC', - 'Europe/Paris', 'America/New_York') - enabled: - type: boolean - description: >- - Whether the schedule is currently active and will trigger - jobs - script_path: - type: string - description: Path to the script or flow to execute when triggered - is_flow: - type: boolean - description: >- - True if script_path points to a flow, false if it points - to a script - args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - extra_perms: - type: object - additionalProperties: - type: boolean - description: Additional permissions for this schedule - email: - type: string - description: >- - Email of the user who owns this schedule, used for - permissioned_as - permissioned_as: - type: string - description: >- - The user or group this schedule runs as (e.g., 'u/admin' - or 'g/mygroup') - error: - type: string - nullable: true - description: Last error message if the schedule failed to trigger - on_failure: - type: string - nullable: true - description: >- - Path to a script or flow to run when the scheduled job - fails - on_failure_times: - type: number - nullable: true - description: >- - Number of consecutive failures before the on_failure - handler is triggered (default 1) - on_failure_exact: - type: boolean - nullable: true - description: >- - If true, trigger on_failure handler only on exactly N - failures, not on every failure after N - on_failure_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_recovery: - type: string - nullable: true - description: >- - Path to a script or flow to run when the schedule recovers - after failures - on_recovery_times: - type: number - nullable: true - description: >- - Number of consecutive successes before the on_recovery - handler is triggered (default 1) - on_recovery_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_success: - type: string - nullable: true - description: >- - Path to a script or flow to run after each successful - execution - on_success_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - ws_error_handler_muted: - type: boolean - description: >- - If true, the workspace-level error handler will not be - triggered for this schedule's failures - retry: - nullable: true - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - summary: - type: string - nullable: true - description: Short summary describing the purpose of this schedule - description: - type: string - nullable: true - description: Detailed description of what this schedule does - no_flow_overlap: - type: boolean - description: >- - If true, skip this schedule's execution if the previous - run is still in progress (prevents concurrent runs) - tag: - type: string - nullable: true - description: Worker tag to route jobs to specific worker groups - paused_until: - type: string - format: date-time - nullable: true - description: >- - ISO 8601 datetime until which the schedule is paused. - Schedule resumes automatically after this time - cron_version: - type: string - nullable: true - description: >- - Cron parser version. Use 'v2' for extended syntax with - additional features - dynamic_skip: - type: string - nullable: true - description: >- - Path to a script that validates scheduled datetimes. - Receives scheduled_for datetime and returns boolean to - skip (true) or run (false) - labels: - type: array - items: - type: string - default: [] - required: &ref_199 - - path - - edited_by - - edited_at - - schedule - - script_path - - timezone - - extra_perms - - is_flow - - enabled - - email - - permissioned_as + allOf: + - type: object + properties: &ref_202 + path: + type: string + description: >- + The unique Windmill path for this schedule. Must be of + the form `u//` or `f//`. + edited_by: + type: string + description: Username of the last person who edited this schedule + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + schedule: + type: string + description: >- + Cron expression with 6 fields (seconds, minutes, + hours, day of month, month, day of week). Example '0 0 + 12 * * *' for daily at noon + timezone: + type: string + description: >- + IANA timezone for the schedule (e.g., 'UTC', + 'Europe/Paris', 'America/New_York') + enabled: + type: boolean + description: >- + Whether the schedule is currently active and will + trigger jobs + script_path: + type: string + description: Path to the script or flow to execute when triggered + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + extra_perms: + type: object + additionalProperties: + type: boolean + description: Additional permissions for this schedule + email: + type: string + description: >- + Email of the user who owns this schedule, used for + permissioned_as + permissioned_as: + type: string + description: >- + The user or group this schedule runs as (e.g., + 'u/admin' or 'g/mygroup') + error: + type: string + nullable: true + description: Last error message if the schedule failed to trigger + on_failure: + type: string + nullable: true + description: >- + Path to a script or flow to run when the scheduled job + fails + on_failure_times: + type: number + nullable: true + description: >- + Number of consecutive failures before the on_failure + handler is triggered (default 1) + on_failure_exact: + type: boolean + nullable: true + description: >- + If true, trigger on_failure handler only on exactly N + failures, not on every failure after N + on_failure_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_recovery: + type: string + nullable: true + description: >- + Path to a script or flow to run when the schedule + recovers after failures + on_recovery_times: + type: number + nullable: true + description: >- + Number of consecutive successes before the on_recovery + handler is triggered (default 1) + on_recovery_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_success: + type: string + nullable: true + description: >- + Path to a script or flow to run after each successful + execution + on_success_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + ws_error_handler_muted: + type: boolean + description: >- + If true, the workspace-level error handler will not be + triggered for this schedule's failures + retry: + nullable: true + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + summary: + type: string + nullable: true + description: Short summary describing the purpose of this schedule + description: + type: string + nullable: true + description: Detailed description of what this schedule does + no_flow_overlap: + type: boolean + description: >- + If true, skip this schedule's execution if the + previous run is still in progress (prevents concurrent + runs) + tag: + type: string + nullable: true + description: Worker tag to route jobs to specific worker groups + paused_until: + type: string + format: date-time + nullable: true + description: >- + ISO 8601 datetime until which the schedule is paused. + Schedule resumes automatically after this time + cron_version: + type: string + nullable: true + description: >- + Cron parser version. Use 'v2' for extended syntax with + additional features + dynamic_skip: + type: string + nullable: true + description: >- + Path to a script that validates scheduled datetimes. + Receives scheduled_for datetime and returns boolean to + skip (true) or run (false) + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + schedule at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_203 + - path + - edited_by + - edited_at + - schedule + - script_path + - timezone + - extra_perms + - is_flow + - enabled + - email + - permissioned_as + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/schedules/exists/{path}: get: summary: does schedule exists @@ -22438,7 +23081,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: path description: filter by path (script path) in: query @@ -22482,6 +23125,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: schedule list @@ -22491,8 +23143,8 @@ paths: type: array items: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 /w/{workspace}/schedules/list_with_jobs: get: summary: list schedules with last 20 jobs @@ -22520,10 +23172,10 @@ paths: schema: type: array items: - allOf: &ref_435 + allOf: &ref_437 - type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 - type: object properties: jobs: @@ -22601,10 +23253,10 @@ paths: application/json: schema: type: object - properties: &ref_201 + properties: &ref_205 info: type: object - properties: &ref_445 + properties: &ref_447 title: type: string version: @@ -22633,28 +23285,28 @@ paths: type: string required: - name - required: &ref_446 + required: &ref_448 - title - version url: type: string openapi_spec_format: type: string - enum: &ref_440 + enum: &ref_442 - yaml - json http_route_filters: type: array items: type: object - properties: &ref_441 + properties: &ref_443 folder_regex: type: string path_regex: type: string route_path_regex: type: string - required: &ref_442 + required: &ref_444 - folder_regex - path_regex - route_path_regex @@ -22662,7 +23314,7 @@ paths: type: array items: type: object - properties: &ref_443 + properties: &ref_445 user_or_folder_regex: type: string enum: @@ -22675,8 +23327,8 @@ paths: type: string runnable_kind: type: string - enum: *ref_200 - required: &ref_444 + enum: *ref_204 + required: &ref_446 - user_or_folder_regex - user_or_folder_regex_value - path @@ -22705,7 +23357,7 @@ paths: application/json: schema: type: object - properties: *ref_201 + properties: *ref_205 responses: '200': description: Downloaded OpenAPI spec @@ -22734,7 +23386,7 @@ paths: type: array items: type: object - properties: &ref_202 + properties: &ref_206 path: type: string description: >- @@ -22788,7 +23440,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: &ref_204 + enum: &ref_208 - get - post - put @@ -22810,7 +23462,7 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: &ref_205 + enum: &ref_209 - sync - async - sync_sse @@ -22820,7 +23472,7 @@ paths: 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: &ref_206 + enum: &ref_210 - none - windmill - api_key @@ -22838,7 +23490,7 @@ paths: mode: description: job trigger mode type: string - enum: &ref_207 + enum: &ref_211 - enabled - disabled - suspended @@ -22859,7 +23511,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -22875,7 +23527,7 @@ paths: type: array items: type: string - required: &ref_203 + required: &ref_207 - path - script_path - route_path @@ -22908,8 +23560,8 @@ paths: application/json: schema: type: object - properties: *ref_202 - required: *ref_203 + properties: *ref_206 + required: *ref_207 responses: '201': description: http trigger created @@ -22939,7 +23591,7 @@ paths: application/json: schema: type: object - properties: &ref_447 + properties: &ref_449 path: type: string description: >- @@ -22999,7 +23651,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_204 + enum: *ref_208 is_async: type: boolean description: Deprecated, use request_type instead @@ -23009,14 +23661,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_205 + enum: *ref_209 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_206 + enum: *ref_210 is_static_website: type: boolean description: >- @@ -23040,7 +23692,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23056,7 +23708,7 @@ paths: type: array items: type: string - required: &ref_448 + required: &ref_450 - path - script_path - is_flow @@ -23108,167 +23760,239 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: http trigger deleted content: application/json: schema: - allOf: &ref_208 - - type: object + allOf: + - allOf: &ref_213 + - type: object + properties: &ref_219 + path: + type: string + description: >- + The unique Windmill path for this trigger. Must be + of the form `u//` or + `f//`. This is the trigger object + path, not the HTTP route path. + script_path: + type: string + description: >- + Path to the script or flow to execute when + triggered + permissioned_as: + type: string + description: >- + The user or group this trigger runs as + (permissioned_as) + extra_perms: + type: object + description: Additional permissions for this trigger + additionalProperties: + type: boolean + workspace_id: + type: string + description: The workspace this trigger belongs to + edited_by: + type: string + description: >- + Username of the last person who edited this + trigger + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + mode: + description: job trigger mode + type: string + enum: *ref_211 + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + trigger at the same path. Set by list endpoints + when + + `include_draft_only=true` synthesizes the row from + the + + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at + this path + + (over a deployed row or a synthesized draft-only + row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_220 + - path + - script_path + - permissioned_as + - extra_perms + - workspace_id + - edited_by + - edited_at + - is_flow + - mode + type: object properties: &ref_214 - path: + route_path: type: string description: >- - The unique Windmill path for this trigger. Must be of - the form `u//` or `f//`. - This is the trigger object path, not the HTTP route - path. - script_path: - type: string - description: Path to the script or flow to execute when triggered - permissioned_as: - type: string - description: >- - The user or group this trigger runs as - (permissioned_as) - extra_perms: + The URL route path that will trigger this endpoint + (e.g., 'api/myendpoint'). Must NOT start with a /. + static_asset_config: type: object - description: Additional permissions for this trigger - additionalProperties: - type: boolean - workspace_id: + nullable: true + description: >- + Configuration for serving static assets (s3 bucket, + storage path, filename) + properties: + s3: + type: string + description: S3 bucket path for static assets + storage: + type: string + description: Storage path for static assets + filename: + type: string + description: Filename for the static asset + required: + - s3 + http_method: + description: >- + HTTP method (get, post, put, delete, patch) that + triggers this endpoint type: string - description: The workspace this trigger belongs to - edited_by: + enum: *ref_208 + authentication_resource_path: type: string - description: Username of the last person who edited this trigger - edited_at: + nullable: true + description: >- + Path to the resource containing authentication + configuration (for api_key, basic_http, custom_script, + signature methods) + summary: type: string - format: date-time - description: Timestamp of the last edit - is_flow: + nullable: true + description: Short summary describing the purpose of this trigger + description: + type: string + nullable: true + description: Detailed description of what this trigger does + request_type: + description: >- + How the request is handled - 'sync' waits for result, + 'async' returns job ID immediately, 'sync_sse' streams + results via Server-Sent Events + type: string + enum: *ref_209 + authentication_method: + description: >- + How requests are authenticated - 'none' (public), + 'windmill' (Windmill token), 'api_key', 'basic_http', + 'custom_script', 'signature' + type: string + enum: *ref_210 + is_static_website: type: boolean description: >- - True if script_path points to a flow, false if it - points to a script - mode: - description: job trigger mode + If true, serves static files from S3/storage instead + of running a script + workspaced_route: + type: boolean + description: >- + If true, the route includes the workspace ID in the + path + wrap_body: + type: boolean + description: If true, wraps the request body in a 'body' parameter + raw_string: + type: boolean + description: >- + If true, passes the request body as a raw string + instead of parsing as JSON + error_handler_path: type: string - enum: *ref_207 - labels: - type: array - items: - type: string - default: [] + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 required: &ref_215 - - path - - script_path - - permissioned_as - - extra_perms - - workspace_id - - edited_by - - edited_at - - is_flow - - mode - type: object - properties: &ref_209 - route_path: - type: string - description: >- - The URL route path that will trigger this endpoint (e.g., - 'api/myendpoint'). Must NOT start with a /. - static_asset_config: - type: object - nullable: true - description: >- - Configuration for serving static assets (s3 bucket, - storage path, filename) - properties: - s3: - type: string - description: S3 bucket path for static assets - storage: - type: string - description: Storage path for static assets - filename: - type: string - description: Filename for the static asset - required: - - s3 - http_method: - description: >- - HTTP method (get, post, put, delete, patch) that triggers - this endpoint - type: string - enum: *ref_204 - authentication_resource_path: - type: string - nullable: true - description: >- - Path to the resource containing authentication - configuration (for api_key, basic_http, custom_script, - signature methods) - summary: - type: string - nullable: true - description: Short summary describing the purpose of this trigger - description: - type: string - nullable: true - description: Detailed description of what this trigger does - request_type: - description: >- - How the request is handled - 'sync' waits for result, - 'async' returns job ID immediately, 'sync_sse' streams - results via Server-Sent Events - type: string - enum: *ref_205 - authentication_method: - description: >- - How requests are authenticated - 'none' (public), - 'windmill' (Windmill token), 'api_key', 'basic_http', - 'custom_script', 'signature' - type: string - enum: *ref_206 - is_static_website: - type: boolean - description: >- - If true, serves static files from S3/storage instead of - running a script - workspaced_route: - type: boolean - description: If true, the route includes the workspace ID in the path - wrap_body: - type: boolean - description: If true, wraps the request body in a 'body' parameter - raw_string: - type: boolean - description: >- - If true, passes the request body as a raw string instead - of parsing as JSON - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_210 - - route_path - - request_type - - authentication_method - - http_method - - is_static_website - - workspaced_route - - wrap_body - - raw_string + - route_path + - request_type + - authentication_method + - http_method + - is_static_website + - workspaced_route + - wrap_body + - raw_string + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/http_triggers/list: get: summary: list http triggers @@ -23307,6 +24031,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: http trigger list @@ -23315,10 +24049,10 @@ paths: schema: type: array items: - allOf: *ref_208 + allOf: *ref_213 type: object - properties: *ref_209 - required: *ref_210 + properties: *ref_214 + required: *ref_215 /w/{workspace}/http_triggers/exists/{path}: get: summary: does http trigger exists @@ -23364,7 +24098,7 @@ paths: type: string http_method: type: string - enum: *ref_204 + enum: *ref_208 trigger_path: type: string workspaced_route: @@ -23404,7 +24138,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -23437,7 +24171,7 @@ paths: application/json: schema: type: object - properties: &ref_449 + properties: &ref_451 path: type: string description: >- @@ -23462,7 +24196,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 filters: type: array description: >- @@ -23493,7 +24227,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: &ref_211 + anyOf: &ref_216 - type: object properties: raw_message: @@ -23536,7 +24270,7 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: &ref_212 + properties: &ref_217 interval_secs: type: integer minimum: 1 @@ -23553,7 +24287,7 @@ paths: Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message. - required: &ref_213 + required: &ref_218 - interval_secs - message error_handler_path: @@ -23566,7 +24300,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23582,7 +24316,7 @@ paths: type: array items: type: string - required: &ref_450 + required: &ref_452 - path - script_path - url @@ -23619,7 +24353,7 @@ paths: application/json: schema: type: object - properties: &ref_451 + properties: &ref_453 url: type: string description: >- @@ -23671,7 +24405,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_211 + anyOf: *ref_216 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -23689,8 +24423,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_212 - required: *ref_213 + properties: *ref_217 + required: *ref_218 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23701,7 +24435,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23717,7 +24451,7 @@ paths: type: array items: type: string - required: &ref_452 + required: &ref_454 - path - script_path - url @@ -23769,103 +24503,149 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: websocket trigger deleted content: application/json: schema: - allOf: &ref_216 + allOf: + - allOf: &ref_221 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_222 + url: + type: string + description: >- + The WebSocket URL to connect to (can be a static URL + or computed by a runnable) + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + filters: + type: array + description: >- + Array of key-value filters to match incoming messages + (only matching messages trigger the script) + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + initial_messages: + type: array + nullable: true + description: >- + Messages to send immediately after connecting (can be + raw strings or computed by runnables) + items: + anyOf: *ref_216 + url_runnable_args: + description: The arguments to pass to the script or flow + nullable: true + type: object + additionalProperties: true + can_return_message: + type: boolean + description: >- + If true, the script can return a message to send back + through the WebSocket + can_return_error_result: + type: boolean + description: >- + If true, error results are sent back through the + WebSocket + heartbeat: + nullable: true + description: Optional periodic heartbeat message configuration + type: object + properties: *ref_217 + required: *ref_218 + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_223 + - url + - filters + - can_return_message + - can_return_error_result - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_217 - url: - type: string - description: >- - The WebSocket URL to connect to (can be a static URL or - computed by a runnable) - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - filters: - type: array - description: >- - Array of key-value filters to match incoming messages - (only matching messages trigger the script) - items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - initial_messages: - type: array - nullable: true - description: >- - Messages to send immediately after connecting (can be raw - strings or computed by runnables) - items: - anyOf: *ref_211 - url_runnable_args: - description: The arguments to pass to the script or flow - nullable: true - type: object - additionalProperties: true - can_return_message: - type: boolean - description: >- - If true, the script can return a message to send back - through the WebSocket - can_return_error_result: - type: boolean - description: If true, error results are sent back through the WebSocket - heartbeat: - nullable: true - description: Optional periodic heartbeat message configuration - type: object - properties: *ref_212 - required: *ref_213 - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_218 - - url - - filters - - can_return_message - - can_return_error_result + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/websocket_triggers/list: get: summary: list websocket triggers @@ -23904,6 +24684,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: websocket trigger list @@ -23912,10 +24702,10 @@ paths: schema: type: array items: - allOf: *ref_216 + allOf: *ref_221 type: object - properties: *ref_217 - required: *ref_218 + properties: *ref_222 + required: *ref_223 /w/{workspace}/websocket_triggers/exists/{path}: get: summary: does websocket trigger exists @@ -23964,7 +24754,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24034,7 +24824,7 @@ paths: application/json: schema: type: object - properties: &ref_484 + properties: &ref_486 path: type: string description: >- @@ -24103,7 +24893,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24114,7 +24904,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24130,7 +24920,7 @@ paths: type: array items: type: string - required: &ref_485 + required: &ref_487 - path - script_path - is_flow @@ -24167,7 +24957,7 @@ paths: application/json: schema: type: object - properties: &ref_486 + properties: &ref_488 kafka_resource_path: type: string description: >- @@ -24243,7 +25033,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24259,7 +25049,7 @@ paths: type: array items: type: string - required: &ref_487 + required: &ref_489 - path - script_path - kafka_resource_path @@ -24311,98 +25101,143 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: kafka trigger deleted content: application/json: schema: - allOf: &ref_219 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_220 - kafka_resource_path: - type: string - description: >- - Path to the Kafka resource containing connection - configuration - group_id: - type: string - description: Kafka consumer group ID for this trigger - topics: - type: array - items: - type: string - description: Array of Kafka topic names to subscribe to - filters: - type: array - items: - type: object - properties: - key: + allOf: + - allOf: &ref_224 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_225 + kafka_resource_path: + type: string + description: >- + Path to the Kafka resource containing connection + configuration + group_id: + type: string + description: Kafka consumer group ID for this trigger + topics: + type: array + items: type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - auto_offset_reset: - type: string - enum: - - latest - - earliest - default: latest - description: >- - Initial offset behavior when consumer group has no - committed offset. 'latest' starts from new messages only, - 'earliest' starts from the beginning. - auto_commit: - type: boolean - default: true - description: >- - When true (default), offsets are committed automatically - after receiving each message. When false, you must - manually commit offsets using the commit_offsets endpoint. - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_221 - - kafka_resource_path - - group_id - - topics - - filters + description: Array of Kafka topic names to subscribe to + filters: + type: array + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + auto_offset_reset: + type: string + enum: + - latest + - earliest + default: latest + description: >- + Initial offset behavior when consumer group has no + committed offset. 'latest' starts from new messages + only, 'earliest' starts from the beginning. + auto_commit: + type: boolean + default: true + description: >- + When true (default), offsets are committed + automatically after receiving each message. When + false, you must manually commit offsets using the + commit_offsets endpoint. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_226 + - kafka_resource_path + - group_id + - topics + - filters + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/kafka_triggers/list: get: summary: list kafka triggers @@ -24441,6 +25276,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: kafka trigger list @@ -24449,10 +25294,10 @@ paths: schema: type: array items: - allOf: *ref_219 + allOf: *ref_224 type: object - properties: *ref_220 - required: *ref_221 + properties: *ref_225 + required: *ref_226 /w/{workspace}/kafka_triggers/exists/{path}: get: summary: does kafka trigger exists @@ -24501,7 +25346,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24620,7 +25465,7 @@ paths: application/json: schema: type: object - properties: &ref_488 + properties: &ref_490 path: type: string description: >- @@ -24663,7 +25508,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24674,7 +25519,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24690,7 +25535,7 @@ paths: type: array items: type: string - required: &ref_489 + required: &ref_491 - path - script_path - is_flow @@ -24726,7 +25571,7 @@ paths: application/json: schema: type: object - properties: &ref_490 + properties: &ref_492 nats_resource_path: type: string description: >- @@ -24776,7 +25621,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24792,7 +25637,7 @@ paths: type: array items: type: string - required: &ref_491 + required: &ref_493 - path - script_path - nats_resource_path @@ -24843,72 +25688,117 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: nats trigger deleted content: application/json: schema: - allOf: &ref_222 + allOf: + - allOf: &ref_227 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_228 + nats_resource_path: + type: string + description: >- + Path to the NATS resource containing connection + configuration + use_jetstream: + type: boolean + description: >- + If true, uses NATS JetStream for durable message + delivery + stream_name: + type: string + nullable: true + description: >- + JetStream stream name (required when use_jetstream is + true) + consumer_name: + type: string + nullable: true + description: >- + JetStream consumer name (required when use_jetstream + is true) + subjects: + type: array + items: + type: string + description: Array of NATS subjects to subscribe to + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_229 + - nats_resource_path + - use_jetstream + - subjects - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_223 - nats_resource_path: - type: string - description: >- - Path to the NATS resource containing connection - configuration - use_jetstream: - type: boolean - description: If true, uses NATS JetStream for durable message delivery - stream_name: - type: string - nullable: true - description: >- - JetStream stream name (required when use_jetstream is - true) - consumer_name: - type: string - nullable: true - description: >- - JetStream consumer name (required when use_jetstream is - true) - subjects: - type: array - items: - type: string - description: Array of NATS subjects to subscribe to - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_224 - - nats_resource_path - - use_jetstream - - subjects + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/nats_triggers/list: get: summary: list nats triggers @@ -24947,6 +25837,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: nats trigger list @@ -24955,10 +25855,10 @@ paths: schema: type: array items: - allOf: *ref_222 + allOf: *ref_227 type: object - properties: *ref_223 - required: *ref_224 + properties: *ref_228 + required: *ref_229 /w/{workspace}/nats_triggers/exists/{path}: get: summary: does nats trigger exists @@ -25007,7 +25907,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25070,7 +25970,7 @@ paths: application/json: schema: type: object - properties: &ref_471 + properties: &ref_473 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25079,7 +25979,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: &ref_225 + enum: &ref_230 - oidc - credentials aws_resource_path: @@ -25114,7 +26014,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25125,7 +26025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25141,7 +26041,7 @@ paths: type: array items: type: string - required: &ref_472 + required: &ref_474 - queue_url - aws_resource_path - path @@ -25177,7 +26077,7 @@ paths: application/json: schema: type: object - properties: &ref_473 + properties: &ref_475 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25186,7 +26086,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_225 + enum: *ref_230 aws_resource_path: type: string description: >- @@ -25219,7 +26119,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25230,7 +26130,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25246,7 +26146,7 @@ paths: type: array items: type: string - required: &ref_474 + required: &ref_476 - queue_url - aws_resource_path - path @@ -25298,69 +26198,112 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: sqs trigger deleted content: application/json: schema: - allOf: &ref_226 + allOf: + - allOf: &ref_231 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_232 + queue_url: + type: string + description: The full URL of the AWS SQS queue to poll for messages + aws_auth_resource_type: + description: >- + Authentication type - 'credentials' for access + key/secret, 'oidc' for OpenID Connect + type: string + enum: *ref_230 + aws_resource_path: + type: string + description: >- + Path to the AWS resource containing credentials or + OIDC configuration + message_attributes: + type: array + nullable: true + items: + type: string + description: >- + Array of SQS message attribute names to include with + each message + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_233 + - queue_url + - aws_resource_path + - aws_auth_resource_type - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_227 - queue_url: - type: string - description: The full URL of the AWS SQS queue to poll for messages - aws_auth_resource_type: - description: >- - Authentication type - 'credentials' for access key/secret, - 'oidc' for OpenID Connect - type: string - enum: *ref_225 - aws_resource_path: - type: string - description: >- - Path to the AWS resource containing credentials or OIDC - configuration - message_attributes: - type: array - nullable: true - items: - type: string - description: >- - Array of SQS message attribute names to include with each - message - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_228 - - queue_url - - aws_resource_path - - aws_auth_resource_type + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/sqs_triggers/list: get: summary: list sqs triggers @@ -25399,6 +26342,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: sqs trigger list @@ -25407,10 +26360,10 @@ paths: schema: type: array items: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 /w/{workspace}/sqs_triggers/exists/{path}: get: summary: does sqs trigger exists @@ -25459,7 +26412,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25524,17 +26477,17 @@ paths: type: array items: type: object - properties: &ref_576 + properties: &ref_577 service_name: type: string - enum: &ref_229 + enum: &ref_234 - nextcloud - google - github oauth_data: nullable: true type: object - properties: &ref_230 + properties: &ref_235 client_id: type: string description: The OAuth client ID for the workspace @@ -25549,7 +26502,7 @@ paths: type: string format: uri description: The OAuth redirect URI - required: &ref_231 + required: &ref_236 - client_id - client_secret - base_url @@ -25558,7 +26511,7 @@ paths: type: string nullable: true description: Path to the resource storing the OAuth token - required: &ref_577 + required: &ref_578 - service_name /w/{workspace}/native_triggers/integrations/{service_name}/exists: get: @@ -25576,7 +26529,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: integration exists @@ -25600,7 +26553,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger service required: true @@ -25608,8 +26561,8 @@ paths: application/json: schema: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 responses: '201': description: native trigger service created @@ -25633,7 +26586,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25641,10 +26594,10 @@ paths: application/json: schema: type: object - properties: &ref_232 + properties: &ref_237 redirect_uri: type: string - required: &ref_233 + required: &ref_238 - redirect_uri responses: '200': @@ -25669,7 +26622,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: whether instance sharing is available @@ -25693,7 +26646,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25701,8 +26654,8 @@ paths: application/json: schema: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 responses: '200': description: authorization URL using instance credentials @@ -25726,7 +26679,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: native trigger service deleted @@ -25750,7 +26703,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: OAuth callback data required: true @@ -25799,7 +26752,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger configuration required: true @@ -25808,7 +26761,7 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: &ref_234 + properties: &ref_239 script_path: type: string description: The path to the script or flow that will be triggered @@ -25825,7 +26778,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_235 + required: &ref_240 - script_path - is_flow - service_config @@ -25837,13 +26790,13 @@ paths: schema: type: object description: Response returned when a native trigger is created - properties: &ref_579 + properties: &ref_580 external_id: type: string description: >- The external ID of the created trigger from the external service - required: &ref_580 + required: &ref_581 - external_id /w/{workspace}/native_triggers/{service_name}/update/{external_id}: post: @@ -25866,7 +26819,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25881,8 +26834,8 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 responses: '200': description: native trigger updated @@ -25911,7 +26864,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25928,7 +26881,7 @@ paths: description: >- Full trigger response containing both Windmill data and external service data - properties: &ref_574 + properties: &ref_575 external_id: type: string description: The unique identifier from the external service @@ -25937,7 +26890,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -25964,7 +26917,7 @@ paths: type: object description: Configuration data from the external service additionalProperties: true - required: &ref_575 + required: &ref_576 - external_id - workspace_id - service_name @@ -25993,7 +26946,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26024,7 +26977,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: page description: which page to return (start at 1, default 1) in: query @@ -26049,6 +27002,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: native triggers list @@ -26059,7 +27022,7 @@ paths: items: type: object description: A native trigger stored in Windmill - properties: &ref_572 + properties: &ref_573 external_id: type: string description: The unique identifier from the external service @@ -26068,7 +27031,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -26091,7 +27054,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_573 + required: &ref_574 - external_id - workspace_id - service_name @@ -26115,7 +27078,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26145,7 +27108,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: sync completed successfully @@ -26170,7 +27133,7 @@ paths: type: array items: type: object - properties: &ref_581 + properties: &ref_582 id: type: string name: @@ -26181,7 +27144,7 @@ paths: type: string path: type: string - required: &ref_582 + required: &ref_583 - id - name - path @@ -26206,7 +27169,7 @@ paths: type: array items: type: object - properties: &ref_583 + properties: &ref_584 id: type: string summary: @@ -26214,7 +27177,7 @@ paths: primary: type: boolean default: false - required: &ref_584 + required: &ref_585 - id - summary /w/{workspace}/native_triggers/google/drive/files: @@ -26257,12 +27220,12 @@ paths: application/json: schema: type: object - properties: &ref_587 + properties: &ref_588 files: type: array items: type: object - properties: &ref_585 + properties: &ref_586 id: type: string name: @@ -26272,13 +27235,13 @@ paths: is_folder: type: boolean default: false - required: &ref_586 + required: &ref_587 - id - name - mime_type next_page_token: type: string - required: &ref_588 + required: &ref_589 - files /w/{workspace}/native_triggers/google/drive/shared_drives: get: @@ -26301,12 +27264,12 @@ paths: type: array items: type: object - properties: &ref_589 + properties: &ref_590 id: type: string name: type: string - required: &ref_590 + required: &ref_591 - id - name /w/{workspace}/native_triggers/github/repos: @@ -26330,7 +27293,7 @@ paths: type: array items: type: object - properties: &ref_591 + properties: &ref_592 full_name: type: string name: @@ -26339,7 +27302,7 @@ paths: type: string private: type: boolean - required: &ref_592 + required: &ref_593 - full_name - name - owner @@ -26356,7 +27319,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: workspace_id in: path required: true @@ -26405,7 +27368,7 @@ paths: application/json: schema: type: object - properties: &ref_454 + properties: &ref_456 mqtt_resource_path: type: string description: >- @@ -26415,16 +27378,16 @@ paths: type: array items: type: object - properties: &ref_236 + properties: &ref_241 qos: type: string - enum: &ref_453 + enum: &ref_455 - qos0 - qos1 - qos2 topic: type: string - required: &ref_237 + required: &ref_242 - qos - topic description: >- @@ -26438,7 +27401,7 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: &ref_238 + properties: &ref_243 clean_session: type: boolean v5_config: @@ -26447,7 +27410,7 @@ paths: MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: &ref_239 + properties: &ref_244 clean_start: type: boolean topic_alias_maximum: @@ -26458,7 +27421,7 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: &ref_240 + enum: &ref_245 - v3 - v5 path: @@ -26480,7 +27443,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26491,7 +27454,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26507,7 +27470,7 @@ paths: type: array items: type: string - required: &ref_455 + required: &ref_457 - path - script_path - is_flow @@ -26542,7 +27505,7 @@ paths: application/json: schema: type: object - properties: &ref_456 + properties: &ref_458 mqtt_resource_path: type: string description: >- @@ -26552,8 +27515,8 @@ paths: type: array items: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -26565,19 +27528,19 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_238 + properties: *ref_243 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_239 + properties: *ref_244 client_version: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_240 + enum: *ref_245 path: type: string description: >- @@ -26597,7 +27560,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26608,7 +27571,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26624,7 +27587,7 @@ paths: type: array items: type: string - required: &ref_457 + required: &ref_459 - path - script_path - is_flow @@ -26675,81 +27638,124 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: mqtt trigger deleted content: application/json: schema: - allOf: &ref_241 + allOf: + - allOf: &ref_246 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_247 + mqtt_resource_path: + type: string + description: >- + Path to the MQTT resource containing broker connection + configuration + subscribe_topics: + type: array + items: + type: object + properties: *ref_241 + required: *ref_242 + description: >- + Array of MQTT topics to subscribe to, each with topic + name and QoS level + v3_config: + nullable: true + description: MQTT v3 specific configuration (clean_session) + type: object + properties: *ref_243 + v5_config: + nullable: true + description: >- + MQTT v5 specific configuration (clean_start, + topic_alias_maximum, session_expiry_interval) + type: object + properties: *ref_244 + client_id: + type: string + nullable: true + description: MQTT client ID for this connection + client_version: + nullable: true + description: MQTT protocol version ('v3' or 'v5') + type: string + enum: *ref_245 + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_248 + - subscribe_topics + - mqtt_resource_path - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_242 - mqtt_resource_path: - type: string - description: >- - Path to the MQTT resource containing broker connection - configuration - subscribe_topics: - type: array - items: - type: object - properties: *ref_236 - required: *ref_237 - description: >- - Array of MQTT topics to subscribe to, each with topic name - and QoS level - v3_config: - nullable: true - description: MQTT v3 specific configuration (clean_session) - type: object - properties: *ref_238 - v5_config: - nullable: true - description: >- - MQTT v5 specific configuration (clean_start, - topic_alias_maximum, session_expiry_interval) - type: object - properties: *ref_239 - client_id: - type: string - nullable: true - description: MQTT client ID for this connection - client_version: - nullable: true - description: MQTT protocol version ('v3' or 'v5') - type: string - enum: *ref_240 - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_243 - - subscribe_topics - - mqtt_resource_path + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/mqtt_triggers/list: get: summary: list mqtt triggers @@ -26788,6 +27794,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: mqtt trigger list @@ -26796,10 +27812,10 @@ paths: schema: type: array items: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 /w/{workspace}/mqtt_triggers/exists/{path}: get: summary: does mqtt trigger exists @@ -26848,7 +27864,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -26912,7 +27928,7 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: &ref_244 + properties: &ref_249 gcp_resource_path: type: string description: >- @@ -26920,7 +27936,7 @@ paths: credentials for authentication. subscription_mode: type: string - enum: &ref_249 + enum: &ref_254 - existing - create_update description: >- @@ -26938,7 +27954,7 @@ paths: description: Base URL for push delivery endpoint. delivery_type: type: string - enum: &ref_246 + enum: &ref_251 - push - pull description: >- @@ -26949,7 +27965,7 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: &ref_247 + properties: &ref_252 audience: type: string description: >- @@ -26960,7 +27976,7 @@ paths: description: >- If true, push messages will include OIDC authentication tokens. - required: &ref_248 + required: &ref_253 - authenticate - base_endpoint path: @@ -26982,7 +27998,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 auto_acknowledge_msg: type: boolean description: >- @@ -27009,7 +28025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -27025,7 +28041,7 @@ paths: type: array items: type: string - required: &ref_245 + required: &ref_250 - path - script_path - is_flow @@ -27062,8 +28078,8 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 responses: '200': description: gcp trigger updated @@ -27108,83 +28124,127 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: gcp trigger deleted content: application/json: schema: - allOf: &ref_250 + allOf: + - allOf: &ref_255 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + description: >- + A Google Cloud Pub/Sub trigger that executes a script or + flow when messages are received. + properties: &ref_256 + gcp_resource_path: + type: string + description: >- + Path to the GCP resource containing service account + credentials for authentication. + topic_id: + type: string + description: Google Cloud Pub/Sub topic ID to subscribe to. + subscription_id: + type: string + description: Google Cloud Pub/Sub subscription ID. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal use). + delivery_type: + type: string + enum: *ref_251 + description: >- + Delivery mode for messages. 'push' for HTTP push + delivery where messages are sent to a webhook + endpoint, 'pull' for polling where the trigger + actively fetches messages. + delivery_config: + nullable: true + type: object + description: Configuration for push delivery mode. + properties: *ref_252 + required: *ref_253 + subscription_mode: + type: string + enum: *ref_254 + description: >- + The mode of subscription. 'existing' means using an + existing GCP subscription, while 'create_update' + involves creating or updating a new subscription. + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal use). + error: + type: string + description: Last error message if the trigger failed. + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails. + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_257 + - gcp_resource_path + - topic_id + - subscription_id + - delivery_type + - subscription_mode - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - A Google Cloud Pub/Sub trigger that executes a script or flow - when messages are received. - properties: &ref_251 - gcp_resource_path: - type: string - description: >- - Path to the GCP resource containing service account - credentials for authentication. - topic_id: - type: string - description: Google Cloud Pub/Sub topic ID to subscribe to. - subscription_id: - type: string - description: Google Cloud Pub/Sub subscription ID. - server_id: - type: string - description: >- - ID of the server currently handling this trigger (internal - use). - delivery_type: - type: string - enum: *ref_246 - description: >- - Delivery mode for messages. 'push' for HTTP push delivery - where messages are sent to a webhook endpoint, 'pull' for - polling where the trigger actively fetches messages. - delivery_config: - nullable: true - type: object - description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 - subscription_mode: - type: string - enum: *ref_249 - description: >- - The mode of subscription. 'existing' means using an - existing GCP subscription, while 'create_update' involves - creating or updating a new subscription. - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal use). - error: - type: string - description: Last error message if the trigger failed. - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails. - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_252 - - gcp_resource_path - - topic_id - - subscription_id - - delivery_type - - subscription_mode + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/gcp_triggers/list: get: summary: list gcp triggers @@ -27223,6 +28283,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: gcp trigger list @@ -27231,13 +28301,13 @@ paths: schema: type: array items: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 /w/{workspace}/gcp_triggers/exists/{path}: get: summary: does gcp trigger exists @@ -27286,7 +28356,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27353,10 +28423,10 @@ paths: application/json: schema: type: object - properties: &ref_460 + properties: &ref_462 subscription_id: type: string - required: &ref_461 + required: &ref_463 - subscription_id responses: '200': @@ -27411,10 +28481,10 @@ paths: application/json: schema: type: object - properties: &ref_458 + properties: &ref_460 topic_id: type: string - required: &ref_459 + required: &ref_461 - topic_id responses: '200': @@ -27443,12 +28513,12 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: &ref_253 + properties: &ref_258 azure_resource_path: type: string azure_mode: type: string - enum: &ref_255 + enum: &ref_260 - basic_push - namespace_push - namespace_pull @@ -27480,7 +28550,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string error_handler_args: @@ -27490,7 +28560,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string preserve_permissioned_as: @@ -27499,7 +28569,7 @@ paths: type: array items: type: string - required: &ref_254 + required: &ref_259 - path - script_path - is_flow @@ -27536,8 +28606,8 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 responses: '200': description: azure trigger updated @@ -27582,65 +28652,110 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: azure trigger content: application/json: schema: - allOf: &ref_256 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - An Azure Event Grid trigger that executes a script or flow - when events arrive. - properties: &ref_257 - azure_resource_path: - type: string - azure_mode: - type: string - enum: *ref_255 - description: Azure Event Grid trigger mode. - scope_resource_id: - type: string + allOf: + - allOf: &ref_261 + - type: object + properties: *ref_219 + required: *ref_220 + type: object description: >- - ARM resource ID of the topic (basic) or namespace - (namespace modes). - topic_name: - type: string - nullable: true - description: Topic name within the namespace (namespace modes only). - subscription_name: - type: string - event_type_filters: - type: array - items: - type: string - nullable: true - server_id: - type: string - last_server_ping: - type: string - format: date-time - error: - type: string - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_258 - - azure_resource_path - - azure_mode - - scope_resource_id - - subscription_name + An Azure Event Grid trigger that executes a script or flow + when events arrive. + properties: &ref_262 + azure_resource_path: + type: string + azure_mode: + type: string + enum: *ref_260 + description: Azure Event Grid trigger mode. + scope_resource_id: + type: string + description: >- + ARM resource ID of the topic (basic) or namespace + (namespace modes). + topic_name: + type: string + nullable: true + description: >- + Topic name within the namespace (namespace modes + only). + subscription_name: + type: string + event_type_filters: + type: array + items: + type: string + nullable: true + server_id: + type: string + last_server_ping: + type: string + format: date-time + error: + type: string + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_263 + - azure_resource_path + - azure_mode + - scope_resource_id + - subscription_name + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/azure_triggers/list: get: summary: list azure triggers @@ -27673,6 +28788,16 @@ paths: in: query schema: type: string + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: azure trigger list @@ -27681,13 +28806,13 @@ paths: schema: type: array items: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 /w/{workspace}/azure_triggers/exists/{path}: get: summary: check whether an azure trigger exists @@ -27735,7 +28860,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27767,10 +28892,10 @@ paths: application/json: schema: type: object - properties: &ref_464 + properties: &ref_466 azure_resource_path: type: string - required: &ref_465 + required: &ref_467 - azure_resource_path responses: '200': @@ -27800,10 +28925,10 @@ paths: application/json: schema: type: object - properties: &ref_466 + properties: &ref_468 scope_resource_id: type: string - required: &ref_467 + required: &ref_469 - scope_resource_id responses: '200': @@ -27835,12 +28960,12 @@ paths: application/json: schema: type: object - properties: &ref_468 + properties: &ref_470 scope_resource_id: type: string topic_name: type: string - required: &ref_469 + required: &ref_471 - scope_resource_id - topic_name responses: @@ -27873,10 +28998,10 @@ paths: application/json: schema: type: object - properties: &ref_462 + properties: &ref_464 azure_mode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -27885,7 +29010,7 @@ paths: nullable: true subscription_name: type: string - required: &ref_463 + required: &ref_465 - azure_mode - scope_resource_id - subscription_name @@ -27921,7 +29046,7 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: &ref_259 + properties: &ref_264 id: type: string name: @@ -27930,7 +29055,7 @@ paths: type: string type: type: string - required: &ref_260 + required: &ref_265 - id - name - type @@ -27961,8 +29086,8 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 /w/{workspace}/postgres_triggers/postgres/version/{path}: get: summary: get postgres version @@ -28025,19 +29150,19 @@ paths: application/json: schema: type: object - properties: &ref_478 + properties: &ref_480 postgres_resource_path: type: string relations: type: array items: type: object - properties: &ref_262 + properties: &ref_267 schema_name: type: string table_to_track: type: array - items: &ref_476 + items: &ref_478 type: object properties: table_name: @@ -28050,14 +29175,14 @@ paths: type: string required: - table_name - required: &ref_263 + required: &ref_268 - schema_name - table_to_track language: type: string - enum: &ref_477 + enum: &ref_479 - Typescript - required: &ref_479 + required: &ref_481 - postgres_resource_path - relations - language @@ -28082,7 +29207,7 @@ paths: - name: id in: path required: true - schema: &ref_304 + schema: &ref_309 type: string responses: '200': @@ -28115,7 +29240,7 @@ paths: type: array items: type: object - properties: &ref_475 + properties: &ref_477 slot_name: type: string active: @@ -28142,7 +29267,7 @@ paths: application/json: schema: type: object - properties: &ref_261 + properties: &ref_266 name: type: string responses: @@ -28174,7 +29299,7 @@ paths: application/json: schema: type: object - properties: *ref_261 + properties: *ref_266 responses: '200': description: postgres replication slot deleted @@ -28225,7 +29350,7 @@ paths: in: path required: true description: The name of the publication - schema: &ref_264 + schema: &ref_269 type: string responses: '200': @@ -28234,18 +29359,18 @@ paths: application/json: schema: type: object - properties: &ref_265 + properties: &ref_270 table_to_track: type: array items: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 transaction_to_track: type: array items: type: string - required: &ref_266 + required: &ref_271 - transaction_to_track /w/{workspace}/postgres_triggers/publication/create/{publication}/{path}: post: @@ -28266,7 +29391,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: new publication for postgres required: true @@ -28274,8 +29399,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication created @@ -28302,7 +29427,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: update publication for postgres required: true @@ -28310,8 +29435,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication updated @@ -28338,7 +29463,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 responses: '200': description: postgres publication deleted @@ -28364,7 +29489,7 @@ paths: application/json: schema: type: object - properties: &ref_480 + properties: &ref_482 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28392,7 +29517,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28403,8 +29528,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28415,7 +29540,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28431,7 +29556,7 @@ paths: type: array items: type: string - required: &ref_481 + required: &ref_483 - path - script_path - is_flow @@ -28466,7 +29591,7 @@ paths: application/json: schema: type: object - properties: &ref_482 + properties: &ref_484 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28494,7 +29619,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28505,8 +29630,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28517,7 +29642,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28533,7 +29658,7 @@ paths: type: array items: type: string - required: &ref_483 + required: &ref_485 - path - script_path - is_flow @@ -28585,60 +29710,103 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: get postgres trigger content: application/json: schema: - allOf: &ref_267 + allOf: + - allOf: &ref_272 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_273 + postgres_resource_path: + type: string + description: >- + Path to the PostgreSQL resource containing connection + configuration + publication_name: + type: string + description: >- + Name of the PostgreSQL publication to subscribe to for + change data capture + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + replication_slot_name: + type: string + description: Name of the PostgreSQL logical replication slot to use + error: + type: string + description: Last error message if the trigger failed + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_274 + - postgres_resource_path + - replication_slot_name + - publication_name - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_268 - postgres_resource_path: - type: string - description: >- - Path to the PostgreSQL resource containing connection - configuration - publication_name: - type: string - description: >- - Name of the PostgreSQL publication to subscribe to for - change data capture - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - replication_slot_name: - type: string - description: Name of the PostgreSQL logical replication slot to use - error: - type: string - description: Last error message if the trigger failed - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_269 - - postgres_resource_path - - replication_slot_name - - publication_name + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/postgres_triggers/list: get: summary: list postgres triggers @@ -28677,6 +29845,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: postgres trigger list @@ -28685,10 +29863,10 @@ paths: schema: type: array items: - allOf: *ref_267 + allOf: *ref_272 type: object - properties: *ref_268 - required: *ref_269 + properties: *ref_273 + required: *ref_274 /w/{workspace}/postgres_triggers/exists/{path}: get: summary: does postgres trigger exists @@ -28737,7 +29915,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -28800,7 +29978,7 @@ paths: application/json: schema: type: object - properties: &ref_492 + properties: &ref_494 path: type: string script_path: @@ -28820,11 +29998,11 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 permissioned_as: type: string description: >- @@ -28840,7 +30018,7 @@ paths: type: array items: type: string - required: &ref_493 + required: &ref_495 - path - script_path - local_part @@ -28874,7 +30052,7 @@ paths: application/json: schema: type: object - properties: &ref_494 + properties: &ref_496 path: type: string script_path: @@ -28894,7 +30072,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28910,7 +30088,7 @@ paths: type: array items: type: string - required: &ref_495 + required: &ref_497 - path - script_path - is_flow @@ -28958,34 +30136,77 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: email trigger retrieved content: application/json: schema: - allOf: &ref_270 + allOf: + - allOf: &ref_275 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_276 + local_part: + type: string + workspaced_local_part: + type: boolean + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_277 + - local_part - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_271 - local_part: - type: string - workspaced_local_part: - type: boolean - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_272 - - local_part + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/email_triggers/list: get: summary: list email triggers @@ -29024,6 +30245,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: email trigger list @@ -29032,10 +30263,10 @@ paths: schema: type: array items: - allOf: *ref_270 + allOf: *ref_275 type: object - properties: *ref_271 - required: *ref_272 + properties: *ref_276 + required: *ref_277 /w/{workspace}/email_triggers/exists/{path}: get: summary: does email trigger exists @@ -29117,7 +30348,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -29147,9 +30378,9 @@ paths: type: array items: type: object - required: &ref_496 + required: &ref_498 - name - properties: &ref_497 + properties: &ref_499 name: type: string summary: @@ -29179,9 +30410,9 @@ paths: type: array items: type: object - required: &ref_274 + required: &ref_279 - name - properties: &ref_275 + properties: &ref_280 name: type: string summary: @@ -29200,14 +30431,14 @@ paths: type: array items: type: object - properties: &ref_498 + properties: &ref_500 workspace_id: type: string workspace_name: type: string role: type: string - required: &ref_499 + required: &ref_501 - name /groups/get/{name}: get: @@ -29219,7 +30450,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group @@ -29227,8 +30458,8 @@ paths: application/json: schema: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 /groups/create: post: summary: create instance group @@ -29266,7 +30497,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update instance group required: true @@ -29302,7 +30533,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group deleted @@ -29320,7 +30551,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to add to instance group required: true @@ -29350,7 +30581,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to remove from instance group required: true @@ -29385,7 +30616,7 @@ paths: type: array items: type: object - properties: &ref_276 + properties: &ref_281 name: type: string summary: @@ -29406,7 +30637,7 @@ paths: enum: - superadmin - devops - required: &ref_277 + required: &ref_282 - name /groups/overwrite: post: @@ -29423,8 +30654,8 @@ paths: type: array items: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 responses: '200': description: success message @@ -29460,7 +30691,7 @@ paths: type: array items: type: object - properties: &ref_278 + properties: &ref_283 name: type: string summary: @@ -29473,7 +30704,7 @@ paths: type: object additionalProperties: type: boolean - required: &ref_279 + required: &ref_284 - name /w/{workspace}/groups/listnames: get: @@ -29546,7 +30777,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: updated group required: true @@ -29578,7 +30809,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group deleted @@ -29600,7 +30831,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group @@ -29608,8 +30839,8 @@ paths: application/json: schema: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 /w/{workspace}/groups/adduser/{name}: post: summary: add user to group @@ -29624,7 +30855,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29656,7 +30887,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29688,7 +30919,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -29747,7 +30978,7 @@ paths: type: array items: type: object - properties: &ref_281 + properties: &ref_286 name: type: string owners: @@ -29773,7 +31004,7 @@ paths: (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: &ref_280 + items: &ref_285 type: object required: - path_glob @@ -29794,7 +31025,15 @@ paths: permissioned as. Must be `u/`, `g/`, or an email that exists in this workspace. - required: &ref_282 + labels: + type: array + items: + type: string + description: > + Labels set on the folder. Items inside the folder + inherit them, exposed as `inherited_labels` on scripts + and flows and stamped into job labels at run time. + required: &ref_287 - name - owners - extra_perms @@ -29861,7 +31100,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string required: - name responses: @@ -29885,7 +31128,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update folder required: true @@ -29911,7 +31154,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string responses: '200': description: folder updated @@ -29933,7 +31180,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder deleted @@ -29955,7 +31202,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -29963,8 +31210,8 @@ paths: application/json: schema: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 /w/{workspace}/folders/exists/{name}: get: summary: exists folder @@ -29979,7 +31226,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder exists @@ -30001,7 +31248,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -30043,7 +31290,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: owner user to folder required: true @@ -30077,7 +31324,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added owner to folder required: true @@ -30113,7 +31360,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -30177,7 +31424,7 @@ paths: type: array items: type: object - properties: &ref_500 + properties: &ref_502 worker: type: string worker_instance: @@ -30223,7 +31470,7 @@ paths: type: string native_mode: type: boolean - required: &ref_501 + required: &ref_503 - worker - worker_instance - ping_at @@ -30388,7 +31635,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: a config @@ -30397,12 +31644,12 @@ paths: schema: type: object nullable: true - properties: &ref_384 + properties: &ref_389 alerts: type: array items: type: object - properties: &ref_382 + properties: &ref_387 name: type: string tags_to_monitor: @@ -30415,7 +31662,7 @@ paths: type: integer alert_time_threshold_seconds: type: integer - required: &ref_383 + required: &ref_388 - name - tags_to_monitor - jobs_num_threshold @@ -30431,7 +31678,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: worker group required: true @@ -30454,7 +31701,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: Delete config @@ -30477,12 +31724,12 @@ paths: type: array items: type: object - properties: &ref_547 + properties: &ref_548 name: type: string config: type: object - required: &ref_548 + required: &ref_549 - name /configs/list_autoscaling_events/{worker_group}: get: @@ -30513,7 +31760,7 @@ paths: type: array items: type: object - properties: &ref_551 + properties: &ref_552 id: type: integer format: int64 @@ -30580,7 +31827,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 required: - workspace_id - language @@ -30606,7 +31853,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_dep_names: type: array items: @@ -30946,7 +32193,7 @@ paths: properties: trigger_kind: type: string - enum: &ref_283 + enum: &ref_288 - webhook - http - websocket @@ -30992,11 +32239,11 @@ paths: required: true schema: type: string - enum: *ref_283 + enum: *ref_288 - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31018,7 +32265,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31032,17 +32279,17 @@ paths: type: array items: type: object - properties: &ref_552 + properties: &ref_553 trigger_config: {} trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 error: type: string last_server_ping: type: string format: date-time - required: &ref_553 + required: &ref_554 - trigger_kind /w/{workspace}/capture/list/{runnable_kind}/{path}: get: @@ -31058,7 +32305,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31067,7 +32314,7 @@ paths: in: query schema: type: string - enum: *ref_283 + enum: *ref_288 - name: page description: which page to return (start at 1, default 1) in: query @@ -31085,10 +32332,10 @@ paths: type: array items: type: object - properties: &ref_284 + properties: &ref_289 trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 main_args: {} preprocessor_args: {} id: @@ -31096,7 +32343,7 @@ paths: created_at: type: string format: date-time - required: &ref_285 + required: &ref_290 - trigger_kind - main_args - preprocessor_args @@ -31116,7 +32363,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31161,8 +32408,8 @@ paths: application/json: schema: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 delete: summary: delete a capture operationId: deleteCapture @@ -31254,13 +32501,13 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: &ref_286 + schema: &ref_291 type: string - name: runnable_type in: query - schema: &ref_287 + schema: &ref_292 type: string - enum: &ref_392 + enum: &ref_398 - ScriptHash - ScriptPath - FlowPath @@ -31277,7 +32524,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: include_preview in: query schema: @@ -31291,7 +32538,7 @@ paths: type: array items: type: object - properties: &ref_288 + properties: &ref_293 id: type: string name: @@ -31305,7 +32552,7 @@ paths: type: boolean success: type: boolean - required: &ref_289 + required: &ref_294 - id - name - args @@ -31355,10 +32602,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 - name: page description: which page to return (start at 1, default 1) in: query @@ -31376,8 +32623,8 @@ paths: type: array items: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 /w/{workspace}/inputs/create: post: summary: Create an Input for future use in a script or flow @@ -31391,10 +32638,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 requestBody: description: Input required: true @@ -31402,12 +32649,12 @@ paths: application/json: schema: type: object - properties: &ref_388 + properties: &ref_394 name: type: string args: type: object - required: &ref_389 + required: &ref_395 - name - args - created_by @@ -31437,14 +32684,14 @@ paths: application/json: schema: type: object - properties: &ref_390 + properties: &ref_396 id: type: string name: type: string is_public: type: boolean - required: &ref_391 + required: &ref_397 - id - name - is_public @@ -31470,7 +32717,7 @@ paths: - name: input in: path required: true - schema: &ref_312 + schema: &ref_317 type: string responses: '200': @@ -31503,7 +32750,7 @@ paths: properties: s3_resource: type: object - properties: &ref_290 + properties: &ref_295 bucket: type: string region: @@ -31518,7 +32765,7 @@ paths: type: string pathStyle: type: boolean - required: &ref_291 + required: &ref_296 - bucket - region - endPoint @@ -31596,8 +32843,8 @@ paths: properties: s3_resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 responses: '200': description: Connection settings @@ -31618,10 +32865,10 @@ paths: type: boolean client_kwargs: type: object - properties: &ref_292 + properties: &ref_297 region_name: type: string - required: &ref_293 + required: &ref_298 - region_name required: - endpoint_url @@ -31676,8 +32923,8 @@ paths: type: boolean client_kwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 required: - endpoint_url - use_ssl @@ -31735,8 +32982,8 @@ paths: application/json: schema: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -31800,10 +33047,10 @@ paths: type: array items: type: object - properties: &ref_294 + properties: &ref_299 s3: type: string - required: &ref_295 + required: &ref_300 - s3 restricted_access: type: boolean @@ -31836,7 +33083,7 @@ paths: application/json: schema: type: object - properties: &ref_298 + properties: &ref_303 mime_type: type: string size_in_bytes: @@ -31900,7 +33147,7 @@ paths: application/json: schema: type: object - properties: &ref_296 + properties: &ref_301 msg: type: string content: @@ -31912,7 +33159,7 @@ paths: - Csv - Parquet - Unknown - required: &ref_297 + required: &ref_302 - content_type /w/{workspace}/job_helpers/list_git_repo_files: get: @@ -31960,8 +33207,8 @@ paths: type: array items: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 restricted_access: type: boolean required: @@ -32020,8 +33267,8 @@ paths: application/json: schema: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 /w/{workspace}/job_helpers/load_git_repo_file_metadata: get: summary: >- @@ -32052,7 +33299,7 @@ paths: application/json: schema: type: object - properties: *ref_298 + properties: *ref_303 /w/{workspace}/job_helpers/check_s3_folder_exists: get: summary: Check if S3 path exists and is a folder @@ -32503,7 +33750,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32532,46 +33779,46 @@ paths: type: array items: type: object - properties: &ref_533 + properties: &ref_534 id: type: string name: type: string - required: &ref_534 + required: &ref_535 - id scalar_metrics: type: array items: type: object - properties: &ref_535 + properties: &ref_536 metric_id: type: string value: type: number - required: &ref_536 + required: &ref_537 - id - value timeseries_metrics: type: array items: type: object - properties: &ref_537 + properties: &ref_538 metric_id: type: string values: type: array items: type: object - properties: &ref_539 + properties: &ref_540 timestamp: type: string format: date-time value: type: number - required: &ref_540 + required: &ref_541 - timestamp - value - required: &ref_538 + required: &ref_539 - id - values /w/{workspace}/job_metrics/set_progress/{id}: @@ -32588,7 +33835,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32622,7 +33869,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job progress between 0 and 99 @@ -32640,11 +33887,11 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 - name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 - name: with_error in: query required: false @@ -32716,12 +33963,12 @@ paths: type: array items: type: object - properties: &ref_541 + properties: &ref_542 concurrency_key: type: string total_running: type: number - required: &ref_542 + required: &ref_543 - concurrency_key - total_running /concurrency_groups/prune/{concurrency_id}: @@ -32734,7 +33981,7 @@ paths: - name: concurrency_id in: path required: true - schema: &ref_314 + schema: &ref_319 type: string responses: '200': @@ -32754,7 +34001,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concurrency key for given job @@ -32790,104 +34037,104 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: page description: which page to return (start at 1, default 1) in: query @@ -32903,7 +34150,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -32924,6 +34171,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -32943,17 +34203,17 @@ paths: application/json: schema: type: object - properties: &ref_543 + properties: &ref_544 jobs: type: array items: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 obscured_jobs: type: array items: type: object - properties: &ref_393 + properties: &ref_399 typ: type: string started_at: @@ -32966,7 +34226,7 @@ paths: Obscured jobs omitted for security because of too specific filtering type: boolean - required: &ref_544 + required: &ref_545 - jobs - obscured_jobs /srch/w/{workspace}/index/search/job: @@ -33010,7 +34270,7 @@ paths: type: array items: type: object - properties: &ref_549 + properties: &ref_550 dancer: type: string hit_count: @@ -33089,7 +34349,7 @@ paths: type: array items: type: object - properties: &ref_550 + properties: &ref_551 dancer: type: string /srch/index/search/count_service_logs: @@ -33364,7 +34624,7 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 usages: type: array items: @@ -33377,13 +34637,13 @@ paths: type: string kind: type: string - enum: &ref_303 + enum: &ref_308 - script - flow - job access_type: type: string - enum: &ref_302 + enum: &ref_307 - r - w - rw @@ -33393,7 +34653,7 @@ paths: description: The columns used (for tables) additionalProperties: type: string - enum: *ref_302 + enum: *ref_307 nullable: true created_at: type: string @@ -33466,7 +34726,7 @@ paths: type: string kind: type: string - enum: *ref_303 + enum: *ref_308 responses: '200': description: all assets used by the given usage paths, in the same order @@ -33486,10 +34746,10 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 access_type: type: string - enum: *ref_302 + enum: *ref_307 nullable: true /w/{workspace}/assets/list_favorites: get: @@ -33537,13 +34797,13 @@ paths: type: array items: type: object - required: &ref_563 + required: &ref_564 - name - size_bytes - file_count - created_at - created_by - properties: &ref_564 + properties: &ref_565 name: type: string size_bytes: @@ -33658,13 +34918,13 @@ paths: type: array items: type: object - required: &ref_376 + required: &ref_381 - name - description - instructions - path - method - properties: &ref_377 + properties: &ref_382 name: type: string description: The tool name/operation ID @@ -33797,11 +35057,30 @@ components: in: cookie name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the deployed + payload. + schema: *ref_77 + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 Id: name: id in: path required: true - schema: *ref_304 + schema: *ref_309 Key: name: key in: path @@ -33817,7 +35096,7 @@ components: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 VersionId: name: version in: path @@ -33828,7 +35107,7 @@ components: name: token in: path required: true - schema: *ref_305 + schema: *ref_310 AccountId: name: id in: path @@ -33843,17 +35122,17 @@ components: name: path in: path required: true - schema: *ref_92 + schema: *ref_97 ScriptHash: name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 JobId: name: id in: path required: true - schema: *ref_172 + schema: *ref_176 Path: name: path in: path @@ -33863,22 +35142,22 @@ components: name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 PathId: name: id in: path required: true - schema: *ref_79 + schema: *ref_84 PathVersion: name: version in: path required: true - schema: *ref_306 + schema: *ref_311 Name: name: name in: path required: true - schema: *ref_273 + schema: *ref_278 Page: name: page description: which page to return (start at 1, default 1) @@ -33897,12 +35176,12 @@ components: '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 OrderDesc: name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 CreatedBy: name: created_by description: >- @@ -33910,7 +35189,7 @@ components: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 Label: name: label description: >- @@ -33918,7 +35197,7 @@ components: 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 Worker: name: worker description: >- @@ -33926,26 +35205,26 @@ components: 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 ParentJob: name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 WorkerTag: name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 CacheTtl: name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 NewJobId: name: job_id description: >- @@ -33953,7 +35232,7 @@ components: randomly using the ULID scheme. If a job id already exists in the queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 IncludeHeader: name: include_header description: > @@ -33963,19 +35242,19 @@ components: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 QueueLimit: name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 SkipPreprocessor: name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 Payload: name: payload description: > @@ -33984,7 +35263,7 @@ components: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 ScriptStartPath: name: script_path_start description: >- @@ -33992,12 +35271,12 @@ components: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 SchedulePath: name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 TriggerPath: name: trigger_path description: >- @@ -34005,7 +35284,7 @@ components: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: *ref_307 + schema: *ref_312 ScriptExactPath: name: script_path_exact description: >- @@ -34013,87 +35292,87 @@ components: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 ScriptExactHash: name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 CreatedBefore: name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 CreatedAfter: name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 StartedBefore: name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 StartedAfter: name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 Before: name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 CompletedBefore: name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 CompletedAfter: name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 CreatedAfterQueue: name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 CreatedBeforeQueue: name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 Success: name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 ScheduledForBeforeNow: name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 Suspended: name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 Running: name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 AllowWildcards: name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 ArgsFilter: name: args description: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 Tag: name: tag description: >- @@ -34101,37 +35380,37 @@ components: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 ResultFilter: name: result description: filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 After: name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 Username: name: username description: filter on exact username of user in: query - schema: *ref_308 + schema: *ref_313 Operation: name: operation description: filter on exact or prefix name of operation in: query - schema: *ref_309 + schema: *ref_314 ResourceName: name: resource description: filter on exact or prefix name of resource in: query - schema: *ref_310 + schema: *ref_315 ActionKind: name: action_kind description: filter on type of operation in: query - schema: *ref_311 + schema: *ref_316 JobKinds: name: job_kinds description: >- @@ -34139,53 +35418,76 @@ components: 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 RunnableId: name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 RunnableTypeQuery: name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 InputId: name: input in: path required: true - schema: *ref_312 + schema: *ref_317 GetStarted: name: get_started in: query - schema: *ref_313 + schema: *ref_318 ConcurrencyId: name: concurrency_id in: path required: true - schema: *ref_314 + schema: *ref_319 RunnableKind: name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 schemas: + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 OpenFlow: type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 Retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 StopAfterIf: type: object description: Early termination condition for a module @@ -34206,6 +35508,13 @@ components: with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised. + error_include_result: + type: boolean + description: >- + When stopping with an error (error_message set), embed the stopping + step's own result inside the raised error object (as error.result) + instead of discarding it. The top-level result stays { error }. + Defaults to false. required: - expr FlowModule: @@ -34222,18 +35531,18 @@ components: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -34252,8 +35561,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: Cache duration in seconds for this step's results @@ -34264,8 +35573,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -34308,8 +35617,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: If true, the user who started the flow cannot approve @@ -34328,7 +35637,7 @@ components: retry: description: Retry configuration for failed module executions type: object - properties: *ref_315 + properties: *ref_320 debouncing: description: Debounce configuration for this step (EE only) type: object @@ -34361,8 +35670,8 @@ components: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 StaticTransform: type: object description: >- @@ -34419,7 +35728,7 @@ components: kind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 resource: type: string description: >- @@ -34439,16 +35748,16 @@ components: oneOf: - type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 - type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 - type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 discriminator: propertyName: kind mapping: @@ -34465,62 +35774,62 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 - type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 - type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 - type: object description: >- Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 - type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 - type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 - type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 - type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 - type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 discriminator: propertyName: type mapping: @@ -34550,8 +35859,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 content: type: string description: The script source code. Should export a 'main' function @@ -34667,8 +35976,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the script in the workspace (e.g., 'f/scripts/send_email') @@ -34705,8 +36014,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the flow in the workspace (e.g., 'f/flows/process_user') @@ -34734,15 +36043,15 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 iterator: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -34762,8 +36071,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34786,8 +36095,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -34807,8 +36116,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34846,8 +36155,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -34857,8 +36166,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: @@ -34896,8 +36205,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -34926,16 +36235,16 @@ components: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 output_type: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -34947,8 +36256,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax. @@ -34958,8 +36267,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. Optional. @@ -34969,8 +36278,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -34980,16 +36289,16 @@ components: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 output_schema: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape. @@ -35006,8 +36315,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -35021,8 +36330,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -35035,8 +36344,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: | Float. Controls randomness/creativity of responses. Range: 0.0 to 2.0 (provider-dependent) @@ -35049,8 +36358,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -35070,12 +36379,17 @@ components: description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. If not set, the AI agent + step runs on the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -35106,8 +36420,8 @@ components: - type FlowStatus: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 FlowStatusModule: type: object properties: @@ -35329,8 +36643,8 @@ components: - type CiTestResult: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 HealthStatusResponse: type: object description: Health status response (cached with 5s TTL) @@ -35344,75 +36658,75 @@ components: HealthChecks: type: object description: Detailed health checks - required: *ref_347 - properties: *ref_348 + required: *ref_352 + properties: *ref_353 DatabaseHealth: type: object description: Database health status - required: *ref_349 - properties: *ref_350 + required: *ref_354 + properties: *ref_355 PoolStats: type: object description: Database connection pool statistics - required: *ref_351 - properties: *ref_352 + required: *ref_356 + properties: *ref_357 WorkersHealth: type: object description: Workers health status - required: *ref_353 - properties: *ref_354 + required: *ref_358 + properties: *ref_359 QueueHealth: type: object description: Job queue status - required: *ref_355 - properties: *ref_356 + required: *ref_360 + properties: *ref_361 ReadinessHealth: type: object description: Server readiness status - required: *ref_357 - properties: *ref_358 + required: *ref_362 + properties: *ref_363 AutoInviteConfig: type: object description: Configuration for auto-inviting users to the workspace - properties: *ref_359 + properties: *ref_364 ErrorHandlerConfig: type: object description: Configuration for the workspace error handler - properties: *ref_360 + properties: *ref_365 SuccessHandlerConfig: type: object description: Configuration for the workspace success handler - properties: *ref_361 + properties: *ref_366 EditErrorHandler: description: >- Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_362 + oneOf: *ref_367 EditErrorHandlerNew: type: object description: New grouped format for editing error handler - properties: *ref_363 + properties: *ref_368 EditErrorHandlerLegacy: type: object description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: *ref_364 + properties: *ref_369 EditSuccessHandler: description: >- Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_365 + oneOf: *ref_370 EditSuccessHandlerNew: type: object description: New grouped format for editing success handler - properties: *ref_366 + properties: *ref_371 EditSuccessHandlerLegacy: type: object description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: *ref_367 + properties: *ref_372 VaultSettings: type: object required: *ref_27 @@ -35427,28 +36741,28 @@ components: properties: *ref_34 SecretMigrationFailure: type: object - required: *ref_368 - properties: *ref_369 + required: *ref_373 + properties: *ref_374 SecretMigrationReport: type: object required: *ref_29 properties: *ref_30 JwksResponse: type: object - required: *ref_370 - properties: *ref_371 + required: *ref_375 + properties: *ref_376 FlowConversation: type: object - required: *ref_372 - properties: *ref_373 + required: *ref_377 + properties: *ref_378 FlowConversationMessage: type: object - required: *ref_374 - properties: *ref_375 + required: *ref_379 + properties: *ref_380 EndpointTool: type: object - required: *ref_376 - properties: *ref_377 + required: *ref_381 + properties: *ref_382 AIProvider: type: string enum: *ref_51 @@ -35461,114 +36775,112 @@ components: required: *ref_44 AIProviderConfig: type: object - properties: *ref_378 - required: *ref_379 + properties: *ref_383 + required: *ref_384 AIConfig: type: object properties: *ref_50 InstanceAIProviderSummary: type: object - properties: *ref_380 - required: *ref_381 + properties: *ref_385 + required: *ref_386 InstanceAISummary: type: object properties: *ref_52 required: *ref_53 Alert: type: object - properties: *ref_382 - required: *ref_383 + properties: *ref_387 + required: *ref_388 Configs: type: object nullable: true - properties: *ref_384 + properties: *ref_389 WorkspaceDependencies: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 NewWorkspaceDependencies: type: object - properties: *ref_385 - required: *ref_386 + properties: *ref_390 + required: *ref_391 Script: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 NewScript: type: object - properties: *ref_104 - required: *ref_105 - NewScriptWithDraft: - allOf: *ref_387 + properties: *ref_392 + required: *ref_393 ScriptHistory: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 ScriptArgs: type: object description: The arguments to pass to the script or flow additionalProperties: true Input: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 CreateInput: type: object - properties: *ref_388 - required: *ref_389 + properties: *ref_394 + required: *ref_395 UpdateInput: type: object - properties: *ref_390 - required: *ref_391 + properties: *ref_396 + required: *ref_397 RunnableType: type: string - enum: *ref_392 + enum: *ref_398 QueuedJob: type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 CompletedJob: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 ExportableCompletedJob: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 ExportableQueuedJob: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 ObscuredJob: type: object - properties: *ref_393 + properties: *ref_399 Job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 User: type: object properties: *ref_35 required: *ref_36 UserSource: type: object - properties: *ref_394 - required: *ref_395 + properties: *ref_400 + required: *ref_401 UserUsage: type: object - properties: *ref_396 + properties: *ref_402 Login: type: object - properties: *ref_397 - required: *ref_398 + properties: *ref_403 + required: *ref_404 PasswordResetResponse: type: object properties: *ref_7 required: *ref_8 EditWorkspaceUser: type: object - properties: *ref_399 + properties: *ref_405 OffboardAffectedPaths: type: object properties: *ref_11 @@ -35578,64 +36890,64 @@ components: required: *ref_13 OffboardTokenInfo: type: object - properties: *ref_400 - required: *ref_401 + properties: *ref_406 + required: *ref_407 OffboardRequest: type: object - properties: *ref_402 - required: *ref_403 + properties: *ref_408 + required: *ref_409 OffboardResponse: type: object properties: *ref_14 OffboardSummary: type: object - properties: *ref_404 - required: *ref_405 + properties: *ref_410 + required: *ref_411 GlobalOffboardPreview: type: object - properties: *ref_406 - required: *ref_407 + properties: *ref_412 + required: *ref_413 WorkspaceOffboardPreview: type: object - properties: *ref_408 - required: *ref_409 + properties: *ref_414 + required: *ref_415 GlobalOffboardRequest: type: object - properties: *ref_410 + properties: *ref_416 WorkspaceReassignment: type: object - properties: *ref_411 - required: *ref_412 + properties: *ref_417 + required: *ref_418 TruncatedToken: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 ExternalJwtToken: type: object - properties: *ref_413 - required: *ref_414 + properties: *ref_419 + required: *ref_420 NewToken: type: object - properties: *ref_415 + properties: *ref_421 NewTokenImpersonate: type: object - properties: *ref_416 - required: *ref_417 + properties: *ref_422 + required: *ref_423 ListableVariable: type: object properties: *ref_61 required: *ref_62 ContextualVariable: type: object - properties: *ref_418 - required: *ref_419 + properties: *ref_424 + required: *ref_425 CreateVariable: type: object - properties: *ref_420 - required: *ref_421 + properties: *ref_426 + required: *ref_427 EditVariable: type: object - properties: *ref_422 + properties: *ref_428 AuditLog: type: object properties: *ref_5 @@ -35772,63 +37084,99 @@ components: - has_preprocessor ScriptLang: type: string - enum: *ref_94 + enum: *ref_99 ScriptModule: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 Preview: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 PreviewInline: type: object - properties: *ref_423 - required: *ref_424 + properties: *ref_429 + required: *ref_430 InlineScriptArgs: type: object - properties: *ref_140 + properties: *ref_144 WorkflowTask: type: object - properties: *ref_425 - required: *ref_426 + properties: *ref_431 + required: *ref_432 WorkflowStatusRecord: type: object additionalProperties: type: object - properties: *ref_177 + properties: *ref_181 WorkflowStatus: type: object - properties: *ref_177 + properties: *ref_181 CreateResource: type: object - properties: *ref_427 - required: *ref_428 + properties: *ref_433 + required: *ref_434 EditResource: type: object - properties: *ref_429 + properties: *ref_435 Resource: type: object - properties: *ref_430 - required: *ref_431 + properties: + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. + ws_specific: + type: boolean + required: + - path + - resource_type + - is_oauth ListableResource: type: object - properties: *ref_432 - required: *ref_433 + properties: *ref_80 + required: *ref_81 ResourceType: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 EditResourceType: type: object - properties: *ref_434 + properties: *ref_436 Schedule: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 ScheduleWJobs: - allOf: *ref_435 + allOf: *ref_437 ErrorHandler: type: string enum: @@ -35837,122 +37185,122 @@ components: - teams - email NewSchedule: - type: object - properties: *ref_436 - required: *ref_437 - EditSchedule: type: object properties: *ref_438 required: *ref_439 + EditSchedule: + type: object + properties: *ref_440 + required: *ref_441 JobTriggerKind: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 TriggerMode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 TriggerExtraProperty: type: object - properties: *ref_214 - required: *ref_215 + properties: *ref_219 + required: *ref_220 AuthenticationMethod: type: string - enum: *ref_206 + enum: *ref_210 RunnableKind: type: string - enum: *ref_200 + enum: *ref_204 OpenapiSpecFormat: type: string - enum: *ref_440 + enum: *ref_442 OpenapiHttpRouteFilters: - type: object - properties: *ref_441 - required: *ref_442 - WebhookFilters: type: object properties: *ref_443 required: *ref_444 - OpenapiV3Info: + WebhookFilters: type: object properties: *ref_445 required: *ref_446 - GenerateOpenapiSpec: - type: object - properties: *ref_201 - HttpMethod: - type: string - enum: *ref_204 - HttpRequestType: - type: string - enum: *ref_205 - HttpTrigger: - allOf: *ref_208 - type: object - properties: *ref_209 - required: *ref_210 - NewHttpTrigger: - type: object - properties: *ref_202 - required: *ref_203 - EditHttpTrigger: + OpenapiV3Info: type: object properties: *ref_447 required: *ref_448 - TriggersCount: + GenerateOpenapiSpec: type: object - properties: *ref_125 - WebsocketHeartbeat: + properties: *ref_205 + HttpMethod: + type: string + enum: *ref_208 + HttpRequestType: + type: string + enum: *ref_209 + HttpTrigger: + allOf: *ref_213 type: object - properties: *ref_212 - required: *ref_213 - WebsocketTrigger: - allOf: *ref_216 + properties: *ref_214 + required: *ref_215 + NewHttpTrigger: type: object - properties: *ref_217 - required: *ref_218 - NewWebsocketTrigger: + properties: *ref_206 + required: *ref_207 + EditHttpTrigger: type: object properties: *ref_449 required: *ref_450 - EditWebsocketTrigger: + TriggersCount: + type: object + properties: *ref_129 + WebsocketHeartbeat: + type: object + properties: *ref_217 + required: *ref_218 + WebsocketTrigger: + allOf: *ref_221 + type: object + properties: *ref_222 + required: *ref_223 + NewWebsocketTrigger: type: object properties: *ref_451 required: *ref_452 + EditWebsocketTrigger: + type: object + properties: *ref_453 + required: *ref_454 WebsocketTriggerInitialMessage: - anyOf: *ref_211 + anyOf: *ref_216 MqttQoS: type: string - enum: *ref_453 + enum: *ref_455 MqttV3Config: type: object - properties: *ref_238 + properties: *ref_243 MqttV5Config: type: object - properties: *ref_239 + properties: *ref_244 MqttSubscribeTopic: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 MqttClientVersion: type: string - enum: *ref_240 + enum: *ref_245 MqttTrigger: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 NewMqttTrigger: - type: object - properties: *ref_454 - required: *ref_455 - EditMqttTrigger: type: object properties: *ref_456 required: *ref_457 + EditMqttTrigger: + type: object + properties: *ref_458 + required: *ref_459 DeliveryType: type: string - enum: *ref_246 + enum: *ref_251 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger @@ -35960,19 +37308,19 @@ components: PushConfig: type: object description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 + properties: *ref_252 + required: *ref_253 GcpTrigger: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 SubscriptionMode: type: string - enum: *ref_249 + enum: *ref_254 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new @@ -35980,68 +37328,68 @@ components: GcpTriggerData: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 GetAllTopicSubscription: - type: object - properties: *ref_458 - required: *ref_459 - DeleteGcpSubscription: type: object properties: *ref_460 required: *ref_461 + DeleteGcpSubscription: + type: object + properties: *ref_462 + required: *ref_463 AzureMode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. AzureArmResource: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 AzureDeleteSubscription: type: object - properties: *ref_462 - required: *ref_463 + properties: *ref_464 + required: *ref_465 AzureTrigger: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 AzureTriggerData: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 TestAzureConnection: - type: object - properties: *ref_464 - required: *ref_465 - AzureListTopics: type: object properties: *ref_466 required: *ref_467 - AzureListSubscriptions: + AzureListTopics: type: object properties: *ref_468 required: *ref_469 + AzureListSubscriptions: + type: object + properties: *ref_470 + required: *ref_471 AwsAuthResourceType: type: string - enum: *ref_225 + enum: *ref_230 SqsTrigger: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 LoggedWizardStatus: type: string enum: *ref_21 CustomInstanceDbLogs: type: object - properties: *ref_470 + properties: *ref_472 CustomInstanceDbTag: type: string enum: *ref_22 @@ -36050,109 +37398,109 @@ components: required: *ref_23 properties: *ref_24 NewSqsTrigger: - type: object - properties: *ref_471 - required: *ref_472 - EditSqsTrigger: type: object properties: *ref_473 required: *ref_474 - Slot: - type: object - properties: *ref_261 - SlotList: + EditSqsTrigger: type: object properties: *ref_475 + required: *ref_476 + Slot: + type: object + properties: *ref_266 + SlotList: + type: object + properties: *ref_477 PublicationData: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 TableToTrack: type: array - items: *ref_476 + items: *ref_478 Relations: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 Language: type: string - enum: *ref_477 + enum: *ref_479 TemplateScript: - type: object - properties: *ref_478 - required: *ref_479 - PostgresTrigger: - allOf: *ref_267 - type: object - properties: *ref_268 - required: *ref_269 - NewPostgresTrigger: type: object properties: *ref_480 required: *ref_481 - EditPostgresTrigger: + PostgresTrigger: + allOf: *ref_272 + type: object + properties: *ref_273 + required: *ref_274 + NewPostgresTrigger: type: object properties: *ref_482 required: *ref_483 - KafkaTrigger: - allOf: *ref_219 - type: object - properties: *ref_220 - required: *ref_221 - NewKafkaTrigger: + EditPostgresTrigger: type: object properties: *ref_484 required: *ref_485 - EditKafkaTrigger: + KafkaTrigger: + allOf: *ref_224 + type: object + properties: *ref_225 + required: *ref_226 + NewKafkaTrigger: type: object properties: *ref_486 required: *ref_487 - NatsTrigger: - allOf: *ref_222 - type: object - properties: *ref_223 - required: *ref_224 - NewNatsTrigger: + EditKafkaTrigger: type: object properties: *ref_488 required: *ref_489 - EditNatsTrigger: + NatsTrigger: + allOf: *ref_227 + type: object + properties: *ref_228 + required: *ref_229 + NewNatsTrigger: type: object properties: *ref_490 required: *ref_491 - EmailTrigger: - allOf: *ref_270 - type: object - properties: *ref_271 - required: *ref_272 - NewEmailTrigger: + EditNatsTrigger: type: object properties: *ref_492 required: *ref_493 - EditEmailTrigger: + EmailTrigger: + allOf: *ref_275 + type: object + properties: *ref_276 + required: *ref_277 + NewEmailTrigger: type: object properties: *ref_494 required: *ref_495 + EditEmailTrigger: + type: object + properties: *ref_496 + required: *ref_497 Group: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 InstanceGroup: type: object - required: *ref_496 - properties: *ref_497 + required: *ref_498 + properties: *ref_499 InstanceGroupWithWorkspaces: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 WorkspaceInfo: type: object - properties: *ref_498 - required: *ref_499 + properties: *ref_500 + required: *ref_501 Folder: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 FolderDefaultPermissionedAs: description: > Ordered list of rules applied at create-time when admins or @@ -36160,19 +37508,19 @@ components: `path_glob` matches the item path (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 WorkerPing: - type: object - properties: *ref_500 - required: *ref_501 - UserWorkspaceList: type: object properties: *ref_502 required: *ref_503 - CreateWorkspace: + UserWorkspaceList: type: object properties: *ref_504 required: *ref_505 + CreateWorkspace: + type: object + properties: *ref_506 + required: *ref_507 CreateWorkspaceFork: type: object properties: *ref_19 @@ -36183,15 +37531,15 @@ components: required: *ref_16 DependencyMap: type: object - properties: *ref_506 + properties: *ref_508 DependencyDependent: - type: object - properties: *ref_507 - required: *ref_508 - DependentsAmount: type: object properties: *ref_509 required: *ref_510 + DependentsAmount: + type: object + properties: *ref_511 + required: *ref_512 WorkspaceInvite: type: object properties: *ref_41 @@ -36201,56 +37549,54 @@ components: properties: *ref_39 required: *ref_40 Flow: - allOf: *ref_124 + allOf: *ref_128 ExtraPerms: type: object - additionalProperties: *ref_511 + additionalProperties: *ref_513 FlowMetadata: - type: object - properties: *ref_512 - required: *ref_513 - OpenFlowWPath: - allOf: *ref_126 - FlowPreview: - type: object - properties: *ref_152 - required: *ref_153 - RestartedFrom: - type: object - properties: *ref_151 - Policy: - type: object - properties: *ref_127 - ListableApp: type: object properties: *ref_514 required: *ref_515 - ScopeDefinition: + OpenFlowWPath: + allOf: *ref_130 + FlowPreview: + type: object + properties: *ref_156 + required: *ref_157 + RestartedFrom: + type: object + properties: *ref_155 + Policy: + type: object + properties: *ref_131 + ListableApp: type: object properties: *ref_516 required: *ref_517 - ScopeDomain: + ScopeDefinition: type: object properties: *ref_518 required: *ref_519 - ListableRawApp: + ScopeDomain: type: object properties: *ref_520 required: *ref_521 + ListableRawApp: + type: object + properties: *ref_522 + required: *ref_523 AppWithLastVersion: type: object - properties: *ref_128 - required: *ref_129 - AppWithLastVersionWDraft: - allOf: *ref_522 + properties: *ref_132 + required: *ref_133 AppHistory: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 FlowVersion: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 SlackToken: type: object properties: @@ -36274,11 +37620,11 @@ components: required: *ref_75 HubScriptKind: type: string - enum: *ref_93 + enum: *ref_98 PolarsClientKwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 LargeFileStorage: type: object properties: *ref_45 @@ -36292,35 +37638,35 @@ components: properties: *ref_47 DataTableSchema: type: object - required: *ref_523 - properties: *ref_524 + required: *ref_524 + properties: *ref_525 DataTableTables: type: object - required: *ref_525 - properties: *ref_526 + required: *ref_526 + properties: *ref_527 DataTableTableSchema: type: object - required: *ref_527 - properties: *ref_528 + required: *ref_528 + properties: *ref_529 DynamicInputData: type: object - properties: *ref_529 - required: *ref_530 + properties: *ref_530 + required: *ref_531 WindmillLargeFile: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 WindmillFileMetadata: type: object - properties: *ref_298 + properties: *ref_303 WindmillFilePreview: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 S3Resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 WorkspaceGitSyncSettings: type: object properties: *ref_56 @@ -36332,48 +37678,48 @@ components: properties: *ref_59 S3PermissionRule: type: object - properties: *ref_531 - required: *ref_532 + properties: *ref_532 + required: *ref_533 GitRepositorySettings: type: object properties: *ref_57 required: *ref_58 MetricMetadata: type: object - properties: *ref_533 - required: *ref_534 + properties: *ref_534 + required: *ref_535 ScalarMetric: type: object - properties: *ref_535 - required: *ref_536 + properties: *ref_536 + required: *ref_537 TimeseriesMetric: type: object - properties: *ref_537 - required: *ref_538 + properties: *ref_538 + required: *ref_539 MetricDataPoint: type: object - properties: *ref_539 - required: *ref_540 + properties: *ref_540 + required: *ref_541 RawScriptForDependencies: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 ConcurrencyGroup: type: object - properties: *ref_541 - required: *ref_542 + properties: *ref_542 + required: *ref_543 ExtendedJobs: type: object - properties: *ref_543 - required: *ref_544 + properties: *ref_544 + required: *ref_545 ExportedUser: type: object properties: *ref_9 required: *ref_10 GlobalSetting: type: object - properties: *ref_545 - required: *ref_546 + properties: *ref_546 + required: *ref_547 InstanceConfig: type: object description: >- @@ -36382,35 +37728,35 @@ components: properties: *ref_26 Config: type: object - properties: *ref_547 - required: *ref_548 + properties: *ref_548 + required: *ref_549 ExportedInstanceGroup: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 JobSearchHit: type: object - properties: *ref_549 + properties: *ref_550 LogSearchHit: type: object - properties: *ref_550 + properties: *ref_551 AutoscalingEvent: type: object - properties: *ref_551 + properties: *ref_552 CriticalAlert: type: object properties: *ref_63 CaptureTriggerKind: type: string - enum: *ref_283 + enum: *ref_288 Capture: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 CaptureConfig: type: object - properties: *ref_552 - required: *ref_553 + properties: *ref_553 + required: *ref_554 OperatorSettings: nullable: true type: object @@ -36418,16 +37764,16 @@ components: properties: *ref_38 WorkspaceComparison: type: object - required: *ref_554 - properties: *ref_555 + required: *ref_555 + properties: *ref_556 WorkspaceItemDiff: type: object - required: *ref_556 - properties: *ref_557 + required: *ref_557 + properties: *ref_558 CompareSummary: type: object - required: *ref_558 - properties: *ref_559 + required: *ref_559 + properties: *ref_560 TeamInfo: type: object required: @@ -36448,12 +37794,12 @@ components: description: List of channels within the team items: type: object - required: &ref_560 + required: &ref_561 - channel_id - channel_name - tenant_id - service_url - properties: &ref_561 + properties: &ref_562 channel_id: type: string description: The unique identifier of the channel @@ -36473,11 +37819,11 @@ components: https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/ ChannelInfo: type: object - required: *ref_560 - properties: *ref_561 + required: *ref_561 + properties: *ref_562 GithubInstallations: type: array - items: *ref_562 + items: *ref_563 WorkspaceGithubInstallation: type: object properties: @@ -36490,8 +37836,8 @@ components: - installation_id S3Object: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 TeamsChannel: type: object required: @@ -36518,14 +37864,14 @@ components: minLength: 1 AssetUsageKind: type: string - enum: *ref_303 + enum: *ref_308 AssetUsageAccessType: type: string - enum: *ref_302 + enum: *ref_307 nullable: true AssetKind: type: string - enum: *ref_301 + enum: *ref_306 Asset: type: object properties: @@ -36533,26 +37879,26 @@ components: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 required: - path - kind Volume: type: object - required: *ref_563 - properties: *ref_564 + required: *ref_564 + properties: *ref_565 ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions - required: *ref_565 - properties: *ref_566 + required: *ref_566 + properties: *ref_567 ProtectionRules: type: array description: Configuration of protection restrictions items: *ref_64 ProtectionRuleKind: type: string - enum: *ref_567 + enum: *ref_568 RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -36563,12 +37909,12 @@ components: items: *ref_66 DeploymentRequestEligibleDeployer: type: object - required: *ref_568 - properties: *ref_569 + required: *ref_569 + properties: *ref_570 DeploymentRequestAssignee: type: object - required: *ref_570 - properties: *ref_571 + required: *ref_571 + properties: *ref_572 DeploymentRequestComment: type: object required: *ref_69 @@ -36583,27 +37929,27 @@ components: required: *ref_72 NativeServiceName: type: string - enum: *ref_229 + enum: *ref_234 NativeTrigger: type: object description: A native trigger stored in Windmill - properties: *ref_572 - required: *ref_573 + properties: *ref_573 + required: *ref_574 NativeTriggerWithExternal: type: object description: >- Full trigger response containing both Windmill data and external service data - properties: *ref_574 - required: *ref_575 + properties: *ref_575 + required: *ref_576 WorkspaceIntegrations: type: object - properties: *ref_576 - required: *ref_577 + properties: *ref_577 + required: *ref_578 WorkspaceOAuthConfig: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 WebhookEvent: type: object properties: @@ -36614,7 +37960,7 @@ components: request_type: type: string description: The type of webhook request (define possible values here) - enum: &ref_578 + enum: &ref_579 - async - sync required: @@ -36623,21 +37969,21 @@ components: WebhookRequestType: type: string description: The type of webhook request (define possible values here) - enum: *ref_578 + enum: *ref_579 RedirectUri: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 NativeTriggerData: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 CreateTriggerResponse: type: object description: Response returned when a native trigger is created - properties: *ref_579 - required: *ref_580 + properties: *ref_580 + required: *ref_581 SyncResult: type: object properties: @@ -36661,35 +38007,35 @@ components: - total_windmill NextCloudEventType: type: object - properties: *ref_581 - required: *ref_582 + properties: *ref_582 + required: *ref_583 GoogleCalendarEntry: type: object - properties: *ref_583 - required: *ref_584 + properties: *ref_584 + required: *ref_585 GoogleDriveFile: type: object - properties: *ref_585 - required: *ref_586 + properties: *ref_586 + required: *ref_587 GoogleDriveFilesResponse: type: object - properties: *ref_587 - required: *ref_588 + properties: *ref_588 + required: *ref_589 SharedDriveEntry: type: object - properties: *ref_589 - required: *ref_590 + properties: *ref_590 + required: *ref_591 GithubRepoEntry: type: object - properties: *ref_591 - required: *ref_592 + properties: *ref_592 + required: *ref_593 schemas-StaticTransform: type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 schemas-JavascriptTransform: type: object description: >- @@ -36697,48 +38043,48 @@ components: results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 schemas-AiTransform: type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 + properties: *ref_91 + required: *ref_92 schemas-InputTransform: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 schemas-RawScript: type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 schemas-PathScript: type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 schemas-PathFlow: type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 schemas-FlowModule: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 schemas-ForloopFlow: type: object description: >- @@ -36746,160 +38092,160 @@ components: 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 schemas-WhileloopFlow: type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 schemas-BranchOne: type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 schemas-BranchAll: type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 schemas-Identity: type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 AIProviderKind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 schemas-ProviderConfig: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: *ref_593 - required: *ref_594 + properties: *ref_594 + required: *ref_595 StaticProviderTransform: type: object description: Static provider configuration passed directly to the AI agent - properties: *ref_595 - required: *ref_596 + properties: *ref_596 + required: *ref_597 ProviderTransform: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 MemoryOff: type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 MemoryAuto: type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 MemoryMessage: type: object description: A single message in conversation history - properties: *ref_597 - required: *ref_598 + properties: *ref_598 + required: *ref_599 MemoryManual: type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 schemas-MemoryConfig: description: Conversation memory configuration - oneOf: *ref_599 - discriminator: *ref_600 + oneOf: *ref_600 + discriminator: *ref_601 StaticMemoryTransform: type: object description: Static memory configuration passed directly to the AI agent - properties: *ref_601 - required: *ref_602 + properties: *ref_602 + required: *ref_603 MemoryTransform: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 schemas-FlowModuleValue: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 FlowModuleTool: description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: *ref_603 + allOf: *ref_604 McpToolValue: type: object description: >- Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: *ref_604 - required: *ref_605 + properties: *ref_605 + required: *ref_606 WebsearchToolValue: type: object description: >- A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: *ref_606 - required: *ref_607 + properties: *ref_607 + required: *ref_608 ToolValue: description: >- The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: *ref_608 - discriminator: *ref_609 + oneOf: *ref_609 + discriminator: *ref_610 AgentTool: type: object description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 schemas-AiAgent: type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 schemas-StopAfterIf: type: object description: Early termination condition for a module - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 RetryIf: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 schemas-Retry: type: object description: Retry configuration for failed module executions - properties: *ref_315 + properties: *ref_320 schemas-FlowNote: type: object description: A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 FlowGroup: type: object description: >- @@ -36908,16 +38254,16 @@ components: flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 + properties: *ref_151 + required: *ref_152 schemas-FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_610 - required: *ref_611 + properties: *ref_611 + required: *ref_612 schemas-FlowStatusModule: type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 535f5982d3..2980879e9f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5355,13 +5355,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: variable content: application/json: schema: - $ref: "#/components/schemas/ListableVariable" + allOf: + - $ref: "#/components/schemas/ListableVariable" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/variables/get_value/{path}: get: @@ -5445,6 +5448,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: "200": description: variable list @@ -6678,13 +6690,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: resource content: application/json: schema: - $ref: "#/components/schemas/Resource" + allOf: + - $ref: "#/components/schemas/ListableResource" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/resources/get_value_interpolated/{path}: get: @@ -6825,6 +6840,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: resource list @@ -7813,7 +7837,40 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: @@ -7833,43 +7890,43 @@ paths: items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: list every draft the current user has in this workspace, across all kinds + operationId: listDrafts tags: - draft parameters: - $ref: "#/components/parameters/WorkspaceId" - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: ["flow", "script", "app"] - value: {} - required: - - path - - typ - - enum responses: - "201": - description: draft created + "200": + description: the user's drafts content: - text/plain: + application/json: schema: - type: string + type: array + items: + type: object + properties: + kind: + $ref: "#/components/schemas/UserDraftItemKind" + path: + type: string + summary: + type: string + description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one. + draft_only: + type: boolean + description: No deployed counterpart exists at this path — the draft is the whole item. + created_at: + type: string + format: date-time + required: [kind, path, draft_only, created_at] - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: fetch a single draft's content by workspace username (or the legacy workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -7878,19 +7935,76 @@ paths: in: path required: true schema: - type: string - enum: - - script - - flow - - app + $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" + - name: username + in: query + required: false + description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row. + schema: + type: string responses: "200": - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: [value, created_at] + "404": + description: no draft for that owner at that path + + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: Server timestamp of the client's last known sync for this draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + "200": + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [saved, conflict] + current_timestamp: + type: string + format: date-time + required: [status, current_timestamp] /w/{workspace}/scripts/create: post: @@ -8269,13 +8383,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: script details content: application/json: schema: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/scripts/get_triggers_count/{path}: get: @@ -8313,23 +8430,6 @@ paths: items: $ref: "#/components/schemas/TruncatedToken" - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: script details - content: - application/json: - schema: - $ref: "#/components/schemas/NewScriptWithDraft" - /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -9505,10 +9605,40 @@ paths: - $ref: "#/components/schemas/Flow" - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: @@ -9638,13 +9768,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: flow details content: application/json: schema: - $ref: "#/components/schemas/Flow" + allOf: + - $ref: "#/components/schemas/Flow" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/flows/deployment_status/p/{path}: get: @@ -9732,32 +9865,6 @@ paths: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: flow details with draft - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Flow" - - type: object - properties: - draft: - $ref: "#/components/schemas/Flow" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -9805,8 +9912,6 @@ paths: - $ref: "#/components/schemas/OpenFlowWPath" - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -10068,6 +10173,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: All raw apps @@ -10341,8 +10447,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10396,8 +10500,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10459,13 +10561,24 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: "200": description: app details content: application/json: schema: - $ref: "#/components/schemas/AppWithLastVersion" + allOf: + - $ref: "#/components/schemas/AppWithLastVersion" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/apps/get/lite/{path}: get: @@ -10484,23 +10597,6 @@ paths: schema: $ref: "#/components/schemas/AppWithLastVersion" - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: app details with draft - content: - application/json: - schema: - $ref: "#/components/schemas/AppWithLastVersionWDraft" - /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -13657,13 +13753,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: schedule deleted content: application/json: schema: - $ref: "#/components/schemas/Schedule" + allOf: + - $ref: "#/components/schemas/Schedule" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/schedules/exists/{path}: get: @@ -13735,6 +13834,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: schedule list @@ -13946,13 +14054,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: http trigger deleted content: application/json: schema: - $ref: "#/components/schemas/HttpTrigger" + allOf: + - $ref: "#/components/schemas/HttpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/http_triggers/list: get: @@ -13984,6 +14095,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: http trigger list @@ -14152,13 +14264,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: websocket trigger deleted content: application/json: schema: - $ref: "#/components/schemas/WebsocketTrigger" + allOf: + - $ref: "#/components/schemas/WebsocketTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/websocket_triggers/list: get: @@ -14190,6 +14305,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: websocket trigger list @@ -14357,13 +14473,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: kafka trigger deleted content: application/json: schema: - $ref: "#/components/schemas/KafkaTrigger" + allOf: + - $ref: "#/components/schemas/KafkaTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/kafka_triggers/list: get: @@ -14395,6 +14514,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: kafka trigger list @@ -14603,13 +14723,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: nats trigger deleted content: application/json: schema: - $ref: "#/components/schemas/NatsTrigger" + allOf: + - $ref: "#/components/schemas/NatsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/nats_triggers/list: get: @@ -14641,6 +14764,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: nats trigger list @@ -14803,13 +14927,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: sqs trigger deleted content: application/json: schema: - $ref: "#/components/schemas/SqsTrigger" + allOf: + - $ref: "#/components/schemas/SqsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/sqs_triggers/list: get: @@ -14841,6 +14968,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: sqs trigger list @@ -15296,6 +15424,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: native triggers list @@ -15596,13 +15725,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: mqtt trigger deleted content: application/json: schema: - $ref: "#/components/schemas/MqttTrigger" + allOf: + - $ref: "#/components/schemas/MqttTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/mqtt_triggers/list: get: @@ -15634,6 +15766,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: mqtt trigger list @@ -15796,13 +15929,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: gcp trigger deleted content: application/json: schema: - $ref: "#/components/schemas/GcpTrigger" + allOf: + - $ref: "#/components/schemas/GcpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/gcp_triggers/list: get: @@ -15834,6 +15970,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: gcp trigger list @@ -16063,13 +16200,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: azure trigger content: application/json: schema: - $ref: "#/components/schemas/AzureTrigger" + allOf: + - $ref: "#/components/schemas/AzureTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/azure_triggers/list: get: @@ -16094,6 +16234,7 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: azure trigger list @@ -16606,13 +16747,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: get postgres trigger content: application/json: schema: - $ref: "#/components/schemas/PostgresTrigger" + allOf: + - $ref: "#/components/schemas/PostgresTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/postgres_triggers/list: get: @@ -16644,6 +16788,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: postgres trigger list @@ -16806,13 +16951,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: email trigger retrieved content: application/json: schema: - $ref: "#/components/schemas/EmailTrigger" + allOf: + - $ref: "#/components/schemas/EmailTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/email_triggers/list: get: @@ -16844,6 +16992,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: email trigger list @@ -20569,6 +20718,25 @@ components: name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: When true, overlay the authed user's draft (if any) onto the deployed payload. + schema: + type: boolean + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: + type: boolean Id: name: id in: path @@ -21000,6 +21168,81 @@ components: # This is why it is better to inline each of schemas for better compat # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: | + Other workspace users (and the legacy NULL-email row, if any) + with a saved draft at the same path. Populated only on the + authed user's "get by path" responses for kinds the editor + surfaces a fork banner for (script, flow, app, raw_app). + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: | + Workspace username of the draft owner. `null` represents + the legacy workspace-level (NULL-email) row. Emails never + leave the server. + required: [is_draft] + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github OpenFlow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" FlowValue: @@ -21822,8 +22065,6 @@ components: type: boolean tag: type: string - has_draft: - type: boolean draft_only: type: boolean envs: @@ -21936,8 +22177,6 @@ components: enum: [script, failure, trigger, command, approval, preprocessor] tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -22030,22 +22269,6 @@ components: - content - language - NewScriptWithDraft: - allOf: - - $ref: "#/components/schemas/NewScript" - - type: object - properties: - draft: - $ref: "#/components/schemas/NewScript" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - hash: - type: string - required: - - hash - ScriptHistory: type: object properties: @@ -23120,6 +23343,17 @@ components: format: date-time edited_by: type: string + draft_only: + description: | + True when this row is a per-user draft with no deployed + variable at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - workspace_id - path @@ -23717,6 +23951,17 @@ components: Read-only — edit them on the folder. ws_specific: type: boolean + draft_only: + description: | + True when this row is a per-user draft with no deployed + resource at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - resource_type @@ -23873,6 +24118,17 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + schedule at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean inherited_labels: type: array items: @@ -24194,6 +24450,19 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + trigger at the same path. Set by list endpoints when + `include_draft_only=true` synthesizes the row from the + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - script_path @@ -27030,6 +27299,34 @@ components: items: type: string default: [] + is_draft: + type: boolean + description: | + True when the authed user has a draft for this app — either no + deployed row exists at this path (draft-only) or the user has + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not yet deployed. + Sourced from the draft JSON's `draft_path` field (the editor + only writes it when the typed path differs from the deployed + one). Lets the home list render the meaningful name instead of + the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and the legacy + NULL-email row if any) who have a per-user draft at this + path. Drives the home page's user-avatar circles inside the + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true inherited_labels: type: array items: @@ -27178,19 +27475,6 @@ components: - raw_app - AppWithLastVersionWDraft: - allOf: - - $ref: "#/components/schemas/AppWithLastVersion" - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - AppHistory: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 4a245530e5..029f0e7d1f 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -12,7 +12,7 @@ use crate::{ db::{ApiAuthed, DB}, jobs::RunJobQuery, users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed}, - utils::{check_scopes, WithStarredInfoQuery}, + utils::check_scopes, webhook_util::{WebhookMessage, WebhookShared}, HTTP_CLIENT, }; @@ -58,6 +58,7 @@ use windmill_common::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, JobPayload, RawCode, }, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, @@ -90,7 +91,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { .route("/list_search", get(list_search_apps)) .route("/get/p/{*path}", get(get_app)) .route("/get/lite/{*path}", get(get_app_lite)) - .route("/get/draft/{*path}", get(get_app_w_draft)) .route("/secret_of/{*path}", get(get_secret_id)) .route( "/secret_of_latest_version/{*path}", @@ -155,7 +155,9 @@ pub struct ListableApp { pub execution_mode: String, pub starred: bool, pub edited_at: Option>, - pub has_draft: bool, + /// `Some(true)` only on rows synthesised from the `draft` table; `None` for + /// deployed rows. See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[sqlx(default)] @@ -165,6 +167,20 @@ pub struct ListableApp { pub raw_app: bool, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this app (draft-only or layered + /// over the deployed row). See ListableScript in windmill-types/src/scripts.rs. + #[serde(default, skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(default, skip_serializing_if = "Option::is_none")] @@ -214,20 +230,6 @@ pub struct AppWithLastVersionAndStarred { pub starred: Option, } -#[derive(Serialize, Deserialize, FromRow)] -pub struct AppWithLastVersionAndDraft { - #[sqlx(flatten)] - #[serde(flatten)] - pub app: AppWithLastVersion, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, -} - #[derive(Serialize)] pub struct AppHistory { pub app_id: i64, @@ -306,7 +308,6 @@ pub struct CreateApp { pub summary: String, pub value: sqlx::types::Json>, pub policy: Policy, - pub draft_only: Option, pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, @@ -370,6 +371,7 @@ async fn list_search_apps( async fn list_apps( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -387,10 +389,15 @@ async fn list_apps( "app_version.created_at as edited_at", "app.extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "app_version.raw_app", "app.labels", + "draft.path IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ = 'app') as draft_users", "folder_labels(app.workspace_id, app.path) as inherited_labels", ]) .left() @@ -400,15 +407,19 @@ async fn list_apps( .bind(&authed.username), ) .left() + // `app`/`raw_app` are separate draft kinds over one `app` table — match either + // for `is_draft`. DISTINCT in the subquery: a path with both kinds for the same + // user would otherwise fan the deployed row into two identical entries. + .join( + "(SELECT DISTINCT path, workspace_id FROM draft WHERE typ IN ('app', 'raw_app') AND email = ?) draft" + .bind(&authed.email), + ) + .on("draft.path = app.path AND draft.workspace_id = app.workspace_id") + .left() .join("app_version") .on( "app_version.id = versions[array_upper(versions, 1)]" ) - .left() - .join("draft") - .on( - "draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'" - ) .order_desc("favorite.path IS NOT NULL") .order_by("app_version.created_at", true) .and_where("app.workspace_id = ?".bind(&w_id)) @@ -428,10 +439,6 @@ async fn list_apps( sqlb.and_where_eq("app.path", "?".bind(path_exact)); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("app.draft_only IS NOT TRUE"); - } - if let Some(label) = &lq.label { for l in label.split(',') { sqlb.and_where( @@ -451,12 +458,87 @@ async fn list_apps( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as::<_, ListableApp>(&sql) + let mut rows = sqlx::query_as::<_, ListableApp>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + // Append the authed user's `app`/`raw_app` drafts at paths with no deployed app; + // see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + { + // DISTINCT ON (path), newest first: collapse a path holding both `app` and + // `raw_app` drafts to one row (the home list keyed by `type/path` would crash + // on duplicates). `(email IS NULL)` last keeps the owned row over the legacy one. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at, + typ::text as "typ!" + FROM draft + WHERE workspace_id = $1 + AND typ IN ('app', 'raw_app') + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM app a + WHERE a.workspace_id = draft.workspace_id + AND a.path = draft.path + ) + ORDER BY path, (email IS NULL), created_at DESC"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // App/raw-app drafts are the bare editor value with no `path`, so the editor + // writes a separate `draft_path` only when it differs from deployed; see flows.rs. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableApp { + id: 0, + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + version: 0, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + execution_mode: String::new(), + starred: false, + edited_at: Some(row.created_at), + draft_only: Some(true), + deployment_msg: None, + raw_app: row.typ == "raw_app", + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -578,12 +660,25 @@ async fn get_raw_app_data( // Ok(Json(version)) // } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetAppQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, + /// Picks the draft kind for a draft-only lookup (`/apps_raw/...` → true). + /// Ignored when a deployed row exists — its own `raw_app` column wins. + #[serde(default)] + raw_app: Option, +} + async fn get_app( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -597,9 +692,9 @@ async fn get_app( JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] LEFT JOIN favorite - ON favorite.favorite_kind = 'app' - AND favorite.workspace_id = app.workspace_id - AND favorite.path = app.path + ON favorite.favorite_kind = 'app' + AND favorite.workspace_id = app.workspace_id + AND favorite.path = app.path AND favorite.usr = $3 WHERE app.path = $1 AND app.workspace_id = $2", ) @@ -623,8 +718,27 @@ async fn get_app( }; tx.commit().await?; - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + // Draft kind comes from the deployed row's `raw_app` flag, or for a draft-only + // path from the caller's `raw_app` query param. + let kind = match &app_o { + Some(app) if app.app.raw_app => UserDraftItemKind::RawApp, + Some(_) => UserDraftItemKind::App, + None if query.raw_app.unwrap_or(false) => UserDraftItemKind::RawApp, + None => UserDraftItemKind::App, + }; + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + kind, + path, + query.get_draft, + app_o, + || windmill_common::error::Error::NotFound(format!("App not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn get_app_lite( @@ -655,55 +769,6 @@ async fn get_app_lite( Ok(Json(app)) } -async fn get_app_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("apps:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>( - r#" - SELECT - app.id, - app.path, - app.summary, - app.versions, - app.policy, - app.custom_path, - app.extra_perms, - app_version.value, - app_version.created_at, - app_version.created_by, - app.draft_only, - draft.value AS "draft", - draft.created_at AS "draft_created_at", - app_version.raw_app, - app.labels - FROM app - INNER JOIN app_version - ON app_version.id = app.versions[array_upper(app.versions, 1)] - LEFT JOIN draft - ON app.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'app' - WHERE app.path = $1 - AND app.workspace_id = $2 - "#, - ) - .bind(path.to_owned()) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) -} - async fn get_app_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -1374,25 +1439,27 @@ async fn create_app_internal<'a>( } } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !app.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", &app.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; } let id = sqlx::query_scalar!( "INSERT INTO app - (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) - VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", + (workspace_id, path, summary, policy, versions, custom_path, labels) + VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", w_id, app.path, app.summary, json!(app.policy), - app.draft_only, app.custom_path .as_ref() .map(|s| if s.is_empty() { None } else { Some(s) }) @@ -1598,15 +1665,16 @@ async fn delete_app( .await?; let trash_drafts: Vec = sqlx::query_scalar( - "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; + // Both `app` and `raw_app` draft kinds back the same `app` table. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", path, &w_id ) @@ -1827,7 +1895,6 @@ async fn update_app_internal<'a>( sqlb.and_where_eq("path", "?".bind(&path)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); - sqlb.set("draft_only", "NULL"); if let Some(npath) = &ns.path { if npath != path { require_owner_of_path(&authed, path)?; @@ -2014,12 +2081,15 @@ async fn update_app_internal<'a>( } }; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row) — see create_app_internal. if !ns.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -3281,18 +3351,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> { Ok((permissioned_as, email)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return crate::users::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2", - "app", - ) - .await; -} - async fn exists_app( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b0cd5f61b0..6ab1a296c5 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -6,133 +6,502 @@ * LICENSE-AGPL for a copy of the license. */ -use crate::{ - db::{ApiAuthed, DB}, - users::{maybe_refresh_folders, require_owner_of_path}, -}; +use crate::db::{ApiAuthed, DB}; use axum::{ extract::{Extension, Path}, - routing::{delete, post}, + routing::{get, post}, Json, Router, }; -use hyper::StatusCode; use serde::{Deserialize, Serialize}; -use windmill_common::{db::UserDB, error::Result, utils::StripPath}; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, + variables::{build_crypt, encrypt}, +}; pub fn workspaced_service() -> Router { Router::new() - .route("/create", post(create_draft)) - .route("/delete/{kind}/{*path}", delete(delete_draft)) + .route("/list", get(list_drafts)) + .route("/get/{kind}/{*path}", get(get_draft_for_user)) + .route("/update/{kind}/{*path}", post(update_draft)) } -#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)] -#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")] -#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] -pub enum DraftType { - Script, - Flow, - App, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct Draft { +#[derive(Serialize, sqlx::FromRow)] +pub struct DraftListItem { + pub kind: UserDraftItemKind, pub path: String, - pub value: sqlx::types::Json>, - pub typ: DraftType, + /// Best-effort, read from the draft JSON's `summary` field when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// No deployed counterpart exists at this path — the draft is the whole + /// item. Kinds without a per-path backing table report `true`. + pub draft_only: bool, + pub created_at: chrono::DateTime, } -pub async fn require_writer_of_path( - authed: &ApiAuthed, - path: &str, - w_id: &str, - db: DB, - kind: &DraftType, -) -> Result<()> { - if authed.is_admin { - return Ok(()); - } else if require_owner_of_path(authed, path).is_ok() { - return Ok(()); +/// Every draft the authed user has in this workspace, across all kinds — the +/// single source for the "Review & deploy drafts" page and the home-page +/// draft-count banner. One query over `draft`; `draft_only` is computed per +/// kind against the deployed table. +async fn list_drafts( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result>> { + // Operators have no drafts of their own (they can't write any, see + // `require_can_write_path`), so this list is always empty for them. They + // can still READ some collaborators' drafts via `/drafts/get`. + if authed.is_operator { + return Ok(Json(vec![])); + } + let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query()) + .bind(&w_id) + .bind(&authed.email) + .fetch_all(&db) + .await?; + Ok(Json(rows)) +} + +/// Build the `list_drafts` SQL, generating the `draft_only` CASE from +/// `deployed_table()` (shared single source — can't drift from the access +/// check). Table names come from the closed enum, never user input. Kinds +/// with no path-keyed table get no arm and fall to `ELSE true`. +/// `$1` = workspace_id, `$2` = email. +fn list_drafts_query() -> String { + let mut case = String::from("CASE d.typ::text\n"); + for kind in UserDraftItemKind::ALL { + let Some(table) = kind.deployed_table() else { + continue; + }; + // `script` rows are soft-deleted — a deleted script counts as "not + // deployed". No other backing table has a `deleted` flag. + let extra = if matches!(kind, UserDraftItemKind::Script) { + " AND t.deleted = false" + } else { + "" + }; + case.push_str(&format!( + " WHEN '{}' THEN NOT EXISTS(SELECT 1 FROM {} t WHERE t.workspace_id = d.workspace_id AND t.path = d.path{})\n", + kind.as_str(), + table, + extra + )); + } + case.push_str(" ELSE true\nEND"); + // `(d.email = $2 OR d.email IS NULL)` lists the user's own drafts AND the + // legacy NULL-email rows; `DISTINCT ON (d.path, d.typ)` with `email IS NULL` + // last collapses a (path, kind) that has both to the owned row. + format!( + r#"SELECT DISTINCT ON (d.path, d.typ) + d.path, + d.typ AS kind, + d.created_at, + d.value ->> 'summary' AS summary, + {case} AS draft_only + FROM draft d + WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL) + ORDER BY d.path, d.typ, (d.email IS NULL)"# + ) +} + +#[derive(Deserialize, Debug)] +pub struct SaveDraftRequest { + /// Draft content to save. `null` (or omitted) signals a delete — the + /// row is removed under the same conflict rules as an upsert. + #[serde(default)] + pub value: Option>>, + /// Client's last known sync timestamp. When present and `force` is false, + /// the save is rejected if the server's `created_at` is more recent + /// (another writer moved the row forward). Omit on a first save. + #[serde(default)] + pub last_sync: Option>, + /// Skip the conflict check and unconditionally overwrite the server + /// copy. Use after the client has resolved the conflict locally. + #[serde(default)] + pub force: bool, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SaveDraftStatus { + Saved, + Conflict, +} + +#[derive(Serialize, Debug)] +pub struct SaveDraftResponse { + pub status: SaveDraftStatus, + /// On `saved`: when the change was applied (client remembers it as the + /// next `last_sync`). On `conflict`: the existing row's `created_at`. + pub current_timestamp: chrono::DateTime, +} + +/// Apply the current user's draft at (workspace, kind, path): non-null `value` +/// upserts, `null` (or omitted) deletes. Either way, when the existing row is +/// newer than `last_sync` (and `force` is false) the op is skipped and the +/// response is `status = conflict` + the server's current timestamp. +async fn update_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result> { + let email = &authed.email; + let path = path.to_path(); + require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + + let applied_at = if let Some(value) = &req.value { + // Secret variable values must never sit in `draft.value` in plaintext + // (see `encrypt_secret_variable_value`). + let serialized = if kind == UserDraftItemKind::Variable { + encrypt_secret_variable_value(&db, &w_id, value.0.get()).await? + } else { + serde_json::to_string(value).unwrap() + }; + // Upsert. The conflict check rides on the DO UPDATE WHERE clause — + // when the row is newer than `last_sync`, RETURNING yields nothing. + sqlx::query_scalar!( + r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at) + VALUES ($1, $2, $3, $4, $5::text::json, now()) + ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL + DO UPDATE SET value = EXCLUDED.value, created_at = now() + WHERE $7::bool = true + OR $6::timestamptz IS NULL + OR draft.created_at <= $6::timestamptz + RETURNING created_at"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + serialized, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? } else { - match kind { - DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await, - DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await, - DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await, + // Delete, same conflict rule in the WHERE clause. Returns NULL when + // the row was too new (conflict) OR already absent (idempotent) — + // disambiguated below. + sqlx::query_scalar!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND email = $2 + AND path = $3 + AND typ = $4 + AND ($6::bool = true + OR $5::timestamptz IS NULL + OR created_at <= $5::timestamptz) + RETURNING now() as "now!""#, + &w_id, + email, + path, + kind as UserDraftItemKind, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? + }; + + if let Some(ts) = applied_at { + return Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: ts, + })); + } + + // No row affected: either the row was newer than `last_sync` (conflict), + // or it was a delete with no row present (idempotent ok). Distinguished + // by re-reading. + let existing = sqlx::query_scalar!( + r#"SELECT created_at FROM draft + WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(&db) + .await?; + + match existing { + Some(ts) => Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Conflict, + current_timestamp: ts, + })), + // Delete + nothing-was-there ⇒ report success with server's NOW(). + None => { + let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#) + .fetch_one(&db) + .await?; + Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: now, + })) } } } -async fn create_draft( +/// For variable-kind drafts with `variable.is_secret == true`, encrypt +/// `variable.value` with the workspace crypt key and mark it +/// `$encrypted:` so the secret never persists in plaintext at rest. +/// Already-marked values pass through untouched. Unexpected/malformed shapes +/// pass through unchanged — the draft store is schema-less by design. +async fn encrypt_secret_variable_value(db: &DB, w_id: &str, raw: &str) -> Result { + let Ok(mut v) = serde_json::from_str::(raw) else { + return Ok(raw.to_string()); + }; + let is_secret = v + .get("variable") + .and_then(|x| x.get("is_secret")) + .and_then(|x| x.as_bool()) + .unwrap_or(false); + if is_secret { + if let Some(serde_json::Value::String(s)) = + v.get_mut("variable").and_then(|x| x.get_mut("value")) + { + if !s.is_empty() && !s.starts_with(ENCRYPTED_DRAFT_PREFIX) { + let mc = build_crypt(db, w_id).await?; + *s = format!("{ENCRYPTED_DRAFT_PREFIX}{}", encrypt(&mc, s)); + } + } + } + Ok(v.to_string()) +} + +#[derive(Deserialize, Debug)] +pub struct GetDraftQuery { + /// Workspace username of the draft owner. Omit to fetch the legacy + /// NULL-email row, if any. Resolved to an email server-side — emails are + /// not part of the public draft API. + pub username: Option, +} + +#[derive(Serialize, Debug)] +pub struct DraftForUser { + pub value: sqlx::types::Json>, + pub created_at: chrono::DateTime, +} + +/// Fetch a specific user's (or the legacy NULL row's) draft content at a path. +/// Backs the "other users' drafts" banner in editors. The owner is identified +/// by workspace username so emails never reach the client. +async fn get_draft_for_user( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path(w_id): Path, - Json(draft): Json, -) -> Result<(StatusCode, String)> { - let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await; + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + axum::extract::Query(query): axum::extract::Query, +) -> Result> { + let path = path.to_path(); + // Drawer kinds keep drafts private to their owner (see + // `shares_drafts_across_users`) — also what blocks reading another user's + // secret-variable `$encrypted:` ciphertext. + if !kind.shares_drafts_across_users() { + return Err(Error::NotFound( + "drafts for this item kind are private to their owner".to_string(), + )); + } + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; - let mut tx = user_db.begin(&authed).await?; + // Username -> email, scoped to the workspace. None signals "fetch the + // legacy NULL-email row" (distinct from a username with no draft, which + // 404s below). + let owner_email: Option = if let Some(username) = &query.username { + let email = sqlx::query_scalar!( + r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#, + &w_id, + username, + ) + .fetch_optional(&db) + .await?; + match email { + Some(e) => Some(e), + // The `admins` workspace has no `usr` rows (username IS the email + // there), so accept it as the owner email directly. + None if w_id == "admins" => Some(username.clone()), + None => { + return Err(Error::NotFound(format!( + "no user with username {username} in workspace" + ))) + } + } + } else { + None + }; - require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?; - - sqlx::query!( - "INSERT INTO draft - (workspace_id, path, value, typ) - VALUES ($1, $2, $3::text::json, $4) - ON CONFLICT (workspace_id, path, typ) - DO UPDATE SET value = EXCLUDED.value, created_at = now()", + let row = sqlx::query_as!( + DraftForUser, + r#"SELECT value as "value!: sqlx::types::Json>", created_at + FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND email IS NOT DISTINCT FROM $4"#, &w_id, - draft.path, - //to preserve key orders - serde_json::to_string(&draft.value).unwrap(), - draft.typ as DraftType, + path, + kind as UserDraftItemKind, + owner_email, ) - .execute(&mut *tx) + .fetch_optional(&db) .await?; - tx.commit().await?; - - Ok((StatusCode::CREATED, format!("draft {} created", draft.path))) + row.map(Json).ok_or_else(|| { + Error::NotFound(format!( + "no draft for {} at {path}", + query.username.as_deref().unwrap_or("") + )) + }) } -async fn delete_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", - path.to_path(), - kind as DraftType, - w_id - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - - Ok(format!("deleted draft")) +/// The deployed table RLS resolves item-level `extra_perms` against. +/// Delegates to `UserDraftItemKind::deployed_table()` (the shared single +/// source); `None` kinds fall through to the path-only access check. +fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { + kind.deployed_table() } -// async fn get_draft( -// authed: ApiAuthed, -// Extension(user_db): Extension, -// Path((w_id, path)): Path<(String, StripPath)>, -// ) -> JsonResult { -// let path = path.to_path(); -// let mut tx = user_db.begin(&authed).await?; +/// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are +/// rejected outright. Two layers: +/// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable +/// `f/`) — mirror what RLS reads from the same JWT claims, and are the +/// ENTIRE check for draft-only paths (no deployed row for RLS to use). +/// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for +/// what the path can't answer, above all item-level extra_perms grants. +async fn require_can_write_path( + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + // Operators are read-only and never WRITE drafts. Read access is + // deliberately asymmetric: `require_can_read_path` has no operator block, + // so an operator can still READ a draft they can read via `/drafts/get`, + // mirroring their read access to deployed content. Intended. + if authed.is_operator { + return Err(Error::NotAuthorized( + "operators cannot save drafts".to_string(), + )); + } + // Cheap claim-based namespace checks first: they evaluate the same JWT + // claims RLS reads, so the outcome matches the policies while sparing the + // autosave hot path a DB round-trip. They are also the ENTIRE check for + // draft-only paths (no deployed row for RLS) — without them any member + // could plant a draft in another user's `u/` namespace, surfaced to every + // reader of the path. `require_owner_of_path` covers admin / `u/{own}` / + // folder owner; group membership and the folder WRITE bit are layered on. + if windmill_api_auth::require_owner_of_path(authed, path).is_ok() { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 3 { + match parts[0] { + "g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()), + "f" => { + let folder = parts[1]; + let has_write = |a: &ApiAuthed| { + a.folders + .iter() + .any(|(name, write, owner)| name == folder && (*write || *owner)) + }; + if has_write(authed) { + return Ok(()); + } + let refreshed = + windmill_api_auth::maybe_refresh_folders(path, w_id, authed.clone(), db).await; + if has_write(&refreshed) { + return Ok(()); + } + } + _ => {} + } + } + // Defer to RLS for what the path can't answer (item-level extra_perms + // grants). Postgres applies UPDATE policies to rows locked via `SELECT + // ... FOR UPDATE`, so a returned row means the canonical write policies + // would let this user UPDATE it — no rule re-implemented here. Draft-only + // paths have no row, so the namespace rules above were the whole check. + if let Some(table) = kind.deployed_table() { + // `table` is from the closed enum, never user input. LIMIT 1 keeps the + // probe to one row lock — `script` has a row per version at the path, + // and locking the whole history would serialize against deploys. + let query = format!( + "SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1 FOR UPDATE" + ); + let mut tx = user_db.clone().begin(authed).await?; + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotAuthorized(format!( + "you don't have write permission on {path}" + ))) +} -// let script_o = sqlx::query_as!( -// Draft, -// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#, -// path, -// w_id -// ) -// .fetch_optional(&mut *tx) -// .await?; -// tx.commit().await?; - -// let draft = not_found_if_none(script_o, "draft", path)?; -// Ok(Json(draft)) -// } +/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers: +/// 1. admin → always. +/// 2. Path-prefix match against own `u/{username}` or any folder in +/// `authed.folders` (the precomputed read set, with groups + direct +/// grants already factored in). +/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level +/// extra_perms grants that bypass folder/owner checks. +/// Both "not readable" and "doesn't exist" return 404 — don't leak existence. +/// +/// Operators are deliberately NOT rejected here (unlike +/// `require_can_write_path`): read-only users keep their read access to +/// deployed content, so an operator can view a collaborator's draft for the +/// cross-user kinds they can already read, while never writing one. Drawer +/// kinds never reach this (`get_draft_for_user` rejects them up front). +async fn require_can_read_path( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 2 { + match parts[0] { + "u" if parts[1] == authed.username => return Ok(()), + "f" => { + let folder = parts[1]; + if authed.folders.iter().any(|(name, _, _)| name == folder) { + return Ok(()); + } + } + _ => {} + } + } + if let Some(table) = table_for_kind(kind) { + let mut tx = user_db.clone().begin(authed).await?; + let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1"); + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotFound(format!("no draft visible at {path}"))) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 787e0ceb93..0f5f9c40ca 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -77,8 +77,8 @@ mod capture; mod concurrency_groups; mod db; mod db_health; - mod drafts; + #[cfg(feature = "private")] pub mod ee; pub mod ee_oss; @@ -553,8 +553,8 @@ pub async fn run_server( "/concurrency_groups", concurrency_groups::workspaced_service(), ) - .nest("/embeddings", embeddings::workspaced_service()) .nest("/drafts", drafts::workspaced_service()) + .nest("/embeddings", embeddings::workspaced_service()) .nest("/favorites", favorite::workspaced_service()) .nest("/flows", flows::workspaced_service()) .nest( diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index c7a1b291dd..4073758ba9 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -41,7 +41,6 @@ pub async fn get_item_schema( sqlb.and_where("o.path = ?".bind(&path)); sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)); sqlb.and_where("o.archived = false"); - sqlb.and_where("o.draft_only IS NOT TRUE"); let sql = sqlb.sql().map_err(|e| { tracing::error!("failed to build sql: {}", e); ErrorData::internal_error(format!("failed to build sql: {}", e), None) @@ -147,8 +146,7 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen .bind(&authed.username)); } sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)) - .and_where("o.archived = false") - .and_where("o.draft_only IS NOT TRUE"); + .and_where("o.archived = false"); if item_type == "script" { sqlb.and_where("o.auto_kind IS NULL"); diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index c929a85a84..3d5342e3a5 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -847,8 +847,8 @@ async fn offboard_user_from_workspace<'c>( let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) - SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 56bfd62ce5..271e9497fe 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -70,9 +70,9 @@ async fn list_paths( let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( - (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000) + (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000) UNION - (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000) + (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000) UNION (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000) UNION diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index d53998355b..0da987e675 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -457,8 +457,8 @@ async fn update_username_in_workpsace<'c>( // ---- flows ---- sqlx::query!( r#"INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index d63720d9a9..4f55877e62 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -13,7 +13,6 @@ pub use windmill_api_auth::{check_scopes, require_devops_role, require_super_adm #[cfg(feature = "private")] pub use windmill_common::usernames::generate_instance_wide_unique_username; -pub use windmill_common::utils::WithStarredInfoQuery; #[cfg(feature = "enterprise")] pub use windmill_alerting::{ diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index afb39e2cca..dae1be0614 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -367,7 +367,6 @@ where "edited_by", "permissioned_as", "archived", - "has_draft", "error", "last_server_ping", "server_id", @@ -650,7 +649,6 @@ pub(crate) async fn tarball_workspace( { let scripts = sqlx::query_as::<_, Script>(&format!( "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false - AND (draft_only IS NULL OR draft_only = false) AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", windmill_common::scripts::SCRIPT_COLUMNS, @@ -786,10 +784,10 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)", + WHERE flow.workspace_id = $1 AND flow.archived = false", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -838,8 +836,7 @@ pub(crate) async fn tarball_workspace( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, app.extra_perms, app_version.value, app_version.created_at, app_version.created_by, app_version.raw_app, app.labels from app, app_version - WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)] - AND (app.draft_only IS NULL OR app.draft_only = false)", + WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -918,7 +915,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::http::HttpTrigger; let handler = HttpTrigger; - let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let http_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -948,7 +945,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::websocket::WebsocketTrigger; let handler = WebsocketTrigger; - let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -978,7 +975,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::kafka::KafkaTrigger; let handler = KafkaTrigger; - let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1008,7 +1005,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::sqs::SqsTrigger; let handler = SqsTrigger; - let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1038,7 +1035,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::gcp::GcpTrigger; let handler = GcpTrigger; - let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1068,7 +1065,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::azure::AzureTrigger; let handler = AzureTrigger; - let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1098,7 +1095,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::nats::NatsTrigger; let handler = NatsTrigger; - let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1128,7 +1125,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::postgres::PostgresTrigger; let handler = PostgresTrigger; - let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1158,7 +1155,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::mqtt::MqttTrigger; let handler = MqttTrigger; - let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1188,7 +1185,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::email::EmailTrigger; let handler = EmailTrigger; - let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let email_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 88a7706b88..7e30547d9b 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -38,6 +38,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true serde_yml.workspace = true +erased-serde = "0.4" chrono.workspace = true chrono-tz.workspace = true hex.workspace = true diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index c12d242737..ba42f13d80 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -102,6 +102,7 @@ pub mod teams_oss; pub mod tracing_init; pub mod trashbin; pub mod triggers; +pub mod user_drafts; pub mod usernames; pub mod users; pub mod utils; diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 366ed75e8e..0cbcb4a1c1 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -97,7 +97,6 @@ pub async fn prefetch_cached_script( language: script.language, kind: script.kind, tag: script.tag, - draft_only: script.draft_only, envs: script.envs, dedicated_worker: script.dedicated_worker, ws_error_handler_muted: script.ws_error_handler_muted, @@ -363,7 +362,6 @@ pub async fn clone_script<'c>( language: s.language, kind: Some(s.kind), tag: s.tag, - draft_only: s.draft_only, envs: s.envs, concurrency_settings: concurrency_settings.maybe_fallback( s.runnable_settings.concurrency_key, @@ -410,14 +408,14 @@ pub async fn clone_script<'c>( INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, \ created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs new file mode 100644 index 0000000000..e008ccc658 --- /dev/null +++ b/backend/windmill-common/src/user_drafts.rs @@ -0,0 +1,536 @@ +/* + * Author: Diego Imbert + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Shared types and helpers for the per-user `draft` table. Lives in +//! `windmill-common` so entity crates can use it without depending on the +//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns. + +// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row +// structs `ListableScript`/`ListableFlow` declare `Vec` and +// can't reach `windmill-common` without a cycle). Re-exported here so draft +// handlers keep a single import path. +pub use windmill_types::user_drafts::DraftUserRef; + +use crate::db::DB; +use crate::error::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Item kinds a user can have an autosaved draft on. Must stay in lockstep +/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND` +/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration). +/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`). +#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserDraftItemKind { + Script, + Flow, + App, + RawApp, + Resource, + Variable, + TriggerSchedule, + TriggerWebhook, + TriggerDefaultEmail, + TriggerEmail, + TriggerHttp, + TriggerWebsocket, + TriggerPostgres, + TriggerKafka, + TriggerNats, + TriggerMqtt, + TriggerSqs, + TriggerGcp, + TriggerAzure, + TriggerPoll, + TriggerCli, + TriggerNextcloud, + TriggerGoogle, + TriggerGithub, +} + +impl UserDraftItemKind { + /// The snake_case wire/DB string, for interpolating into dynamically-built + /// SQL (`?::DRAFT_KIND` binds want a string). + pub fn as_str(&self) -> &'static str { + match self { + UserDraftItemKind::Script => "script", + UserDraftItemKind::Flow => "flow", + UserDraftItemKind::App => "app", + UserDraftItemKind::RawApp => "raw_app", + UserDraftItemKind::Resource => "resource", + UserDraftItemKind::Variable => "variable", + UserDraftItemKind::TriggerSchedule => "trigger_schedule", + UserDraftItemKind::TriggerWebhook => "trigger_webhook", + UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email", + UserDraftItemKind::TriggerEmail => "trigger_email", + UserDraftItemKind::TriggerHttp => "trigger_http", + UserDraftItemKind::TriggerWebsocket => "trigger_websocket", + UserDraftItemKind::TriggerPostgres => "trigger_postgres", + UserDraftItemKind::TriggerKafka => "trigger_kafka", + UserDraftItemKind::TriggerNats => "trigger_nats", + UserDraftItemKind::TriggerMqtt => "trigger_mqtt", + UserDraftItemKind::TriggerSqs => "trigger_sqs", + UserDraftItemKind::TriggerGcp => "trigger_gcp", + UserDraftItemKind::TriggerAzure => "trigger_azure", + UserDraftItemKind::TriggerPoll => "trigger_poll", + UserDraftItemKind::TriggerCli => "trigger_cli", + UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud", + UserDraftItemKind::TriggerGoogle => "trigger_google", + UserDraftItemKind::TriggerGithub => "trigger_github", + } + } + + /// Every variant, for code that must enumerate kinds (e.g. generating + /// the `draft_only` existence SQL). + pub const ALL: [UserDraftItemKind; 24] = [ + UserDraftItemKind::Script, + UserDraftItemKind::Flow, + UserDraftItemKind::App, + UserDraftItemKind::RawApp, + UserDraftItemKind::Resource, + UserDraftItemKind::Variable, + UserDraftItemKind::TriggerSchedule, + UserDraftItemKind::TriggerWebhook, + UserDraftItemKind::TriggerDefaultEmail, + UserDraftItemKind::TriggerEmail, + UserDraftItemKind::TriggerHttp, + UserDraftItemKind::TriggerWebsocket, + UserDraftItemKind::TriggerPostgres, + UserDraftItemKind::TriggerKafka, + UserDraftItemKind::TriggerNats, + UserDraftItemKind::TriggerMqtt, + UserDraftItemKind::TriggerSqs, + UserDraftItemKind::TriggerGcp, + UserDraftItemKind::TriggerAzure, + UserDraftItemKind::TriggerPoll, + UserDraftItemKind::TriggerCli, + UserDraftItemKind::TriggerNextcloud, + UserDraftItemKind::TriggerGoogle, + UserDraftItemKind::TriggerGithub, + ]; + + /// The deployed table backing this kind, keyed by `(workspace_id, path)`. + /// SINGLE SOURCE for both the draft access check (which table RLS resolves + /// item-level `extra_perms` against) and the `draft_only` existence check. + /// `None` for kinds with no per-path backing table (webhook is a property + /// of a script/flow row; native triggers are keyed by external_id, not + /// path) — callers treat that as "no deployed counterpart": `draft_only = + /// true` and a path-only access check. + pub fn deployed_table(&self) -> Option<&'static str> { + use UserDraftItemKind::*; + match self { + Script => Some("script"), + Flow => Some("flow"), + App | RawApp => Some("app"), + Resource => Some("resource"), + Variable => Some("variable"), + TriggerSchedule => Some("schedule"), + TriggerHttp => Some("http_trigger"), + TriggerWebsocket => Some("websocket_trigger"), + TriggerPostgres => Some("postgres_trigger"), + TriggerKafka => Some("kafka_trigger"), + TriggerNats => Some("nats_trigger"), + TriggerMqtt => Some("mqtt_trigger"), + TriggerSqs => Some("sqs_trigger"), + TriggerGcp => Some("gcp_trigger"), + TriggerAzure => Some("azure_trigger"), + TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), + TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle + | TriggerGithub => None, + } + } + + /// Whether OTHER users' drafts at a path are visible to a viewer (the + /// "others are editing" list, owner circles, and the `get_draft_for_user` + /// View JSON / Fork endpoint). Enabled only for the full-page editor items + /// which have the cross-user draft UI. Drawer items keep drafts private to + /// their owner: they have no such UI, and exposing a secret variable draft + /// would hand out the `$encrypted:` ciphertext, which a viewer could + /// launder into plaintext via a deploy. + pub fn shares_drafts_across_users(&self) -> bool { + use UserDraftItemKind::*; + matches!(self, Script | Flow | App | RawApp) + } +} + +/// Query-string flag accepted by every "get by path" route that supports +/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct +/// when the route has other query fields. +#[derive(Debug, Deserialize, Default)] +pub struct WithDraftQuery { + /// When true, attach the authed user's draft (if any) as a separate + /// `draft` field. Defaults to false so non-editor callers see the + /// deployed shape unchanged. + #[serde(default)] + pub get_draft: bool, +} + +/// One row of `other_drafts_users`: a draft on the same path owned by +/// someone other than the authed user. `username` is `None` for the legacy +/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry. +#[derive(Debug, Serialize)] +pub struct OtherDraftUser { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} + +/// Response wrapper: the deployed entity untouched plus the authed user's +/// draft (if any) as a sibling `draft` field, which the frontend pairs to +/// diff/restore/discard. The deployed and the draft are NEVER merged on the +/// server — the editor's saved shape can diverge arbitrarily, so any per-kind +/// translation lives in the frontend loader. `inner` is boxed-erased so a +/// possibly MB-scale deployed payload serializes in ONE pass (no +/// `serde_json::Value` round-trip) while keeping the struct non-generic. +#[derive(Serialize)] +pub struct WithDraftOverlay { + /// Deployed payload, flattened to the top level. + #[serde(flatten)] + pub inner: Box, + pub is_draft: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_saved_at: Option>, + /// True when no deployed row exists at this path: `inner` is only a + /// best-effort stand-in synthesized from the draft and only `draft` is + /// canonical. Frontend uses this to disable "diff/reset vs deployed" and + /// skip its deployed-shape parsing of `inner`. Omitted when false. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub no_deployed: bool, + /// The user's saved draft payload (whatever shape the editor wrote). + /// Present when `get_draft=true` and a draft exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option, + /// Other users with a draft on the same path (excludes the authed user). + /// Empty list is omitted to keep the common-case response lean. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub other_drafts_users: Vec, +} + +/// List every other user (and the legacy NULL-email row, if any) with a +/// draft at `(workspace, kind, path)`. Returns usernames only — emails never +/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user +/// removed from the workspace) still surfaces with `username = None`. The +/// authed user is excluded via `email <> authed_email`; the legacy row +/// matches because `email IS NULL` fails that comparison. +async fn fetch_other_drafts_users( + db: &DB, + w_id: &str, + authed_email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // The `admins` workspace has no `usr` rows (username IS the email there), + // so fall back to `d.email` when the join misses, else a real teammate's + // draft renders as a phantom "Legacy draft". The genuine NULL-email legacy + // row keeps `username = None` (its `d.email` is NULL, so the CASE yields NULL). + let rows = sqlx::query_as!( + OtherDraftUser, + r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?" + FROM draft d + LEFT JOIN usr u + ON u.workspace_id = d.workspace_id + AND u.email = d.email + WHERE d.workspace_id = $1 + AND d.path = $2 + AND d.typ = $3 + AND (d.email IS NULL OR d.email <> $4) + ORDER BY d.email NULLS LAST"#, + w_id, + path, + kind as UserDraftItemKind, + authed_email, + ) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// If `get_draft` is true AND the authed user has a draft for +/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always +/// serialized into `inner` untouched. +pub async fn maybe_overlay_draft( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: T, +) -> Result +where + T: serde::Serialize + Send + 'static, +{ + // Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps) + // pass `get_draft = false` and render no overlay, so skip the `usr` join. + if !get_draft { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users: Vec::new(), + }); + } + + // Independent of the authed user's OWN draft: reset-to-deployed reloads + // still need to know who else is editing this path. Only the cross-user + // kinds surface it (see `shares_drafts_across_users`). + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + + // Prefer the user's OWN per-user draft, falling back to the legacy + // NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy + // row when an owned one exists. + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users, + }); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + + Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: false, + draft: Some(draft_json), + other_drafts_users, + }) +} + +/// One row of a "draft-only" list synthesis: a draft at `path` with no +/// deployed counterpart. `value` is the editor's saved JSON (each handler +/// maps it into its own `Listable*` shape). +#[derive(sqlx::FromRow)] +pub struct DraftOnlyListRow { + pub path: String, + pub value: sqlx::types::Json>, + pub created_at: DateTime, +} + +/// Fetch the authed user's draft rows at paths with NO deployed counterpart, +/// for synthesizing draft-only entries into a list response. Absence is +/// checked against `kind.deployed_table()` (the shared single source). +/// Returns empty for kinds with no path-keyed table. Callers keep their own +/// gating (`include_draft_only`, page 0, no filters) and row mapping. +pub async fn fetch_draft_only_list_rows( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, +) -> Result> { + let Some(table) = kind.deployed_table() else { + return Ok(Vec::new()); + }; + // `table` is from the closed `deployed_table()` enum, never user input. + // `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows + // AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL` + // last collapses a path that has both to the owned row. + let sql = format!( + "SELECT DISTINCT ON (path) path, value, created_at FROM draft \ + WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \ + AND (email = $3 OR email IS NULL) \ + AND NOT EXISTS (SELECT 1 FROM {table} t \ + WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \ + ORDER BY path, (email IS NULL)" + ); + let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql) + .bind(w_id) + .bind(kind.as_str()) + .bind(email) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// The get-by-path draft choreography, shared by every entity's "get by path" +/// route. Given the deployed entity as an `Option` (caller maps its own "not +/// found" to `None`): +/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`). +/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when +/// a draft exists, else the caller's 404 via `not_found`. +/// - `None` without `get_draft` → the caller's 404. +pub async fn overlay_or_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: Option, + not_found: impl FnOnce() -> crate::error::Error, +) -> Result { + match deployed { + Some(deployed) => { + maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await + } + None if get_draft => fetch_draft_only(db, w_id, email, kind, path) + .await? + .ok_or_else(not_found), + None => Err(not_found()), + } +} + +/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind. +/// Use when the item is DELETED outright: it's gone for everyone, so leaving +/// teammates' drafts behind would orphan them forever. Discarding one's OWN +/// draft while the item lives on goes through `update_draft` with `value: null`. +/// Idempotent on the no-draft case. +pub async fn delete_all_drafts_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3"#, + w_id, + path, + kind as UserDraftItemKind, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Discard the deploying user's OWN draft (plus the legacy NULL-email row) +/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the +/// item moved, so the draft at the old path is orphaned (no FK to cascade). +/// Teammates keep theirs and get the StaleDraftModal on their next reload. +/// Idempotent on the no-draft case. +pub async fn delete_own_draft_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, + email: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND (email = $4 OR email IS NULL)"#, + w_id, + path, + kind as UserDraftItemKind, + email, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Fetch the authed user's draft as a standalone payload, for "get by path" +/// routes when no deployed row exists but a draft might. Returns it as a +/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and +/// `no_deployed = true`. Callers must have established no deployed row exists; +/// `Ok(None)` when there's also no draft (caller should 404). +/// +/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner` +/// works); a non-object draft renders with no fields flattened. +pub async fn fetch_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`). + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + Ok(Some(WithDraftOverlay { + // Best-effort stand-in for the missing deployed — same JSON as `draft`. + inner: Box::new(draft_json.clone()), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: true, + draft: Some(draft_json), + other_drafts_users, + })) +} + +/// Marker prefix for draft secret values encrypted at rest with the workspace +/// crypt key (`build_crypt`). Written by `update_draft` for secret variables; +/// resolved back to plaintext by the variable deploy endpoints. +pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:"; + +fn draft_decrypt_error() -> crate::error::Error { + crate::error::Error::BadRequest( + "An encrypted draft secret could not be decrypted (the workspace encryption key may \ + have changed since the draft was saved). Reset the field and re-enter the secret." + .to_string(), + ) +} + +/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the +/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt +/// (e.g. the workspace key was rotated after the draft save). +pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result { + let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value); + let mc = crate::variables::build_crypt(db, w_id).await?; + crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error()) +} diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index faabd27142..0d42b95a5a 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -59,6 +59,19 @@ pub struct ListableVariable { pub edited_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub edited_by: Option, + /// True when this row is a per-user draft with no deployed variable + /// at the same path. Surfaced by `include_draft_only` so the frontend + /// can render a "Draft" badge and the editor can open from the draft + /// alone. `None`/omitted on rows fetched from the `variable` table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// layered over a deployed variable or a synthesized draft-only row. + /// Drives the `*` suffix on the variables page. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 85bb906715..44db0a1b68 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -43,6 +43,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{self, Error, JsonResult, Result}, get_database_url, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only, + fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, + WithDraftQuery, + }, utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath}, variables, worker::{CLOUD_HOSTED, WINDMILL_DIR}, @@ -153,6 +158,15 @@ pub struct ListableResource { pub inherited_labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the resources page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Deserialize)] @@ -185,6 +199,9 @@ pub struct ListResourceQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(Serialize, FromRow)] @@ -252,6 +269,7 @@ async fn list_resources( Query(lq): Query, Query(pagination): Query, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let (per_page, offset) = paginate(pagination); @@ -276,6 +294,13 @@ async fn list_resources( "folder_labels(resource.workspace_id, resource.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", ]) + // Scalar EXISTS flags the authed user's per-user draft without fanning rows out. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = resource.workspace_id \ + AND draft.path = resource.path AND draft.typ = 'resource' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("variable") .on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id") @@ -352,7 +377,7 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "resources", "read"); - let rows = sqlx::query_as::<_, ListableResource>(&sql) + let mut rows = sqlx::query_as::<_, ListableResource>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -361,6 +386,101 @@ async fn list_resources( tx.commit().await?; + // Append the authed user's draft-only resources; see scripts.rs. + // `resource_type` / `resource_type_exclude` are deliberately NOT in the bail-out + // list (the resources page always passes `resource_type_exclude`); they're applied + // per-row below against the draft JSON's `resource_type` instead. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let rt_filter: Option> = lq + .resource_type + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let rt_exclude: Option> = lq + .resource_type_exclude + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Resource) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let description = v + .get("description") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let value = v.get("args").cloned(); + let resource_type = v + .get("resource_type") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Mirror the deployed query's resource_type narrowing for the + // synthesized rows (see the gate comment above). + if let Some(ref rts) = rt_filter { + if !rts.contains(&resource_type.as_str()) { + continue; + } + } + if let Some(ref excl) = rt_exclude { + if excl.contains(&resource_type.as_str()) { + continue; + } + } + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableResource { + workspace_id: w_id.clone(), + path, + value, + description, + resource_type, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + created_by: None, + edited_at: Some(row.created_at), + is_linked: None, + is_refreshed: None, + is_oauth: None, + is_expired: None, + refresh_error: None, + account: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -369,13 +489,15 @@ async fn get_resource( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("resources:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let resource_o = sqlx::query_as!( ListableResource, + // `null::bool` columns align with the struct fields; deployed rows are never draft-only. "SELECT resource.workspace_id, resource.path, resource.value, resource.description, resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at, resource.labels, @@ -385,7 +507,9 @@ async fn get_resource( variable.path IS NOT NULL as is_linked, variable.is_oauth as \"is_oauth?\", variable.account, - ws_specific.path IS NOT NULL as ws_specific + ws_specific.path IS NOT NULL as ws_specific, + null::bool as draft_only, + null::bool as is_draft FROM resource LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2 LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2 @@ -397,11 +521,30 @@ async fn get_resource( .fetch_optional(&mut *tx) .await?; tx.commit().await?; + if resource_o.is_none() && q.get_draft { + // No deployed row + `get_draft`: synthesize the response from the draft + // alone (`no_deployed = true`); see scripts.rs. + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await? + { + return Ok(Json(overlay)); + } + } if resource_o.is_none() { explain_resource_perm_error(&path, &w_id, &db, &authed).await?; } let resource = not_found_if_none(resource_o, "Resource", path)?; - Ok(Json(resource)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Resource, + path, + q.get_draft, + resource, + ) + .await?; + Ok(Json(overlay)) } async fn exists_resource( @@ -1168,6 +1311,13 @@ async fn delete_resource( .await?; tx.commit().await?; + // Resource gone for everyone: wipe ALL users' drafts at this path (and any linked + // variables cascaded into) so teammates' drafts don't orphan. Idempotent on no-draft. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + for var_path in &deleted_linked_variables { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + handle_deployment_metadata( &authed.email, &authed.username, @@ -1437,6 +1587,14 @@ async fn delete_resources_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked variables); see delete_resource. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + for var_path in &linked_var_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + try_join_all(deleted_paths.iter().map(|path| { handle_deployment_metadata( &authed.email, @@ -1664,6 +1822,28 @@ async fn update_resource( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the draft at the OLD path orphans (no SQL FK); clear the deployer's + // own (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). The linked + // variable renames alongside the resource, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 347415f27e..72ed20cb71 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -35,6 +35,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{Error, JsonResult, Result}, scripts::ScriptHash, + user_drafts::{ + decrypt_draft_secret_value, delete_all_drafts_for_path, delete_own_draft_for_path, + fetch_draft_only, fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, + WithDraftOverlay, ENCRYPTED_DRAFT_PREFIX, + }, utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt}, variables::{ build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable, @@ -109,11 +114,15 @@ struct ListVariableQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } async fn list_variables( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lq): Query, Query(pagination): Query, @@ -143,6 +152,13 @@ async fn list_variables( "variable.edited_at", "variable.edited_by", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = variable.workspace_id \ + AND draft.path = variable.path AND draft.typ = 'variable' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("account") .on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id)) @@ -199,7 +215,7 @@ async fn list_variables( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "variables", "read"); - let rows = sqlx::query_as::<_, ListableVariable>(&sql) + let mut rows = sqlx::query_as::<_, ListableVariable>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -207,13 +223,102 @@ async fn list_variables( .collect::>(); tx.commit().await?; + + // Append the authed user's draft-only variables; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // VariableEditor's `VariableState`: { path, variable: { value, is_secret, description }, labels?, wsSpecific } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let variable = v + .get("variable") + .cloned() + .unwrap_or(serde_json::Value::Null); + let is_secret = variable + .get("is_secret") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let description = variable + .get("description") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Secret variables never expose their value in the list response, even from a draft. + let value = if is_secret { + None + } else { + variable + .get("value") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + }; + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableVariable { + workspace_id: w_id.clone(), + path, + value, + is_secret, + description, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + account: None, + is_oauth: None, + is_expired: None, + is_refreshed: None, + refresh_error: None, + is_linked: None, + expires_at: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + edited_at: Some(row.created_at), + edited_by: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } +// `get_draft` inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. #[derive(Deserialize)] struct GetVariableQuery { decrypt_secret: Option, include_encrypted: Option, + #[serde(default)] + get_draft: bool, } async fn get_variable( @@ -222,7 +327,7 @@ async fn get_variable( Extension(db): Extension, Query(q): Query, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("variables:read:{}", path))?; @@ -252,6 +357,17 @@ async fn get_variable( let variable = if let Some(variable) = variable_o { variable + } else if q.get_draft { + // No deployed row + `get_draft`: fall back to the draft (see scripts.rs). + // Drop the user_db tx first since `fetch_draft_only` runs on `db`. + tx.commit().await?; + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await? + { + return Ok(Json(overlay)); + } + explain_variable_perm_error(&path, &w_id, &db).await?; + unreachable!() } else { explain_variable_perm_error(&path, &w_id, &db).await?; unreachable!() @@ -310,7 +426,17 @@ async fn get_variable( variable }; - Ok(Json(r)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Variable, + path, + q.get_draft, + r, + ) + .await?; + Ok(Json(overlay)) } #[derive(Deserialize)] @@ -449,8 +575,15 @@ async fn create_variable( check_path_conflict(&db, &w_id, &variable.path).await?; let value = if variable.is_secret && !already_encrypted.unwrap_or(false) { + // A restored draft sends the `$encrypted:` marker as-is; decrypt it back + // (validating against the workspace key) before the secret backend re-stores it. + let plain = if variable.value.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &variable.value).await? + } else { + variable.value.clone() + }; // Use secret backend for encryption (supports both DB and Vault) - store_secret_value(&db, &w_id, &variable.path, &variable.value).await? + store_secret_value(&db, &w_id, &variable.path, &plain).await? } else { variable.value }; @@ -647,6 +780,13 @@ async fn delete_variable( tx.commit().await?; + // Variable gone for everyone: wipe ALL users' drafts at this path (see resources.rs). + // Resource included because variables cascade-delete the linked resource at the same path. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + if deleted_linked_resource.is_some() { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // If variable was a secret, also delete from Vault backend (if configured) if is_secret { delete_secret_from_backend(&db, &w_id, path).await?; @@ -785,12 +925,12 @@ async fn delete_variables_bulk( ) .execute(&mut *tx) .await?; - sqlx::query!( - "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2", + let deleted_resource_paths = sqlx::query_scalar!( + "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", &deleted_paths, w_id ) - .execute(&mut *tx) + .fetch_all(&mut *tx) .await?; sqlx::query!( @@ -814,6 +954,14 @@ async fn delete_variables_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked resources); see delete_variable. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + } + for path in &deleted_resource_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // Delete secrets from Vault backend (if configured) for path in &secret_paths { if deleted_paths.contains(path) { @@ -918,9 +1066,15 @@ async fn update_variable( }; let value = if is_secret && !already_encrypted.unwrap_or(false) { + // Decrypt a restored draft's `$encrypted:` marker before re-storing; see create_variable. + let plain = if nvalue.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &nvalue).await? + } else { + nvalue + }; // Use secret backend for encryption (supports both DB and Vault) // Store at target_path (new path if renaming, otherwise current path) - store_secret_value(&db, &w_id, target_path, &nvalue).await? + store_secret_value(&db, &w_id, target_path, &plain).await? } else { nvalue }; @@ -1171,6 +1325,27 @@ async fn update_variable( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the old-path draft orphans (see resources.rs); the linked resource + // renames alongside the variable, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index ae2de880a4..c9c019c6e8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -773,7 +773,6 @@ pub async fn assert_lockfile( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -796,6 +795,7 @@ pub async fn assert_lockfile( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await @@ -871,7 +871,6 @@ pub async fn run_deployed_relative_imports( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -894,6 +893,7 @@ pub async fn run_deployed_relative_imports( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await diff --git a/backend/windmill-trigger-azure/src/handler_oss.rs b/backend/windmill-trigger-azure/src/handler_oss.rs index e56c76a2fd..30257203b6 100644 --- a/backend/windmill-trigger-azure/src/handler_oss.rs +++ b/backend/windmill-trigger-azure/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for AzureTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerAzure; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/azure_triggers"; diff --git a/backend/windmill-trigger-email/src/handler_oss.rs b/backend/windmill-trigger-email/src/handler_oss.rs index b6cac94cc2..fccbde4b96 100644 --- a/backend/windmill-trigger-email/src/handler_oss.rs +++ b/backend/windmill-trigger-email/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for EmailTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerEmail; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/email_triggers"; diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index 1cacf7f594..c7e444f5ac 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for GcpTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerGcp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/gcp_triggers"; diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 74fd748f55..7b617e1b8d 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -373,6 +373,7 @@ impl TriggerCrud for HttpTrigger { const TABLE_NAME: &'static str = "http_trigger"; const TRIGGER_TYPE: &'static str = "http"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerHttp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/http_triggers"; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index 57e786b0ea..0445664ddb 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for KafkaTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerKafka; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/kafka_triggers"; diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index 49fb701241..f1a6b6ff9d 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -25,6 +25,7 @@ impl TriggerCrud for MqttTrigger { const TABLE_NAME: &'static str = "mqtt_trigger"; const TRIGGER_TYPE: &'static str = "mqtt"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerMqtt; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/mqtt_triggers"; diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index 226bd653fd..b377774ee3 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for NatsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerNats; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/nats_triggers"; diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index dc2f4776fd..10a26e0066 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -46,6 +46,7 @@ impl TriggerCrud for PostgresTrigger { const TABLE_NAME: &'static str = "postgres_trigger"; const TRIGGER_TYPE: &'static str = "postgres"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerPostgres; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/postgres_triggers"; diff --git a/backend/windmill-trigger-sqs/src/handler_oss.rs b/backend/windmill-trigger-sqs/src/handler_oss.rs index fc72159e24..0f54a548fe 100644 --- a/backend/windmill-trigger-sqs/src/handler_oss.rs +++ b/backend/windmill-trigger-sqs/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for SqsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerSqs; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/sqs_triggers"; diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index df8bccaaa6..dd6b9f4363 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -28,6 +28,7 @@ impl TriggerCrud for WebsocketTrigger { const TABLE_NAME: &'static str = "websocket_trigger"; const TRIGGER_TYPE: &'static str = "websocket"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerWebsocket; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/websocket_triggers"; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 3591c88931..4f3d381569 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -16,6 +16,10 @@ use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows, + overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, @@ -60,7 +64,10 @@ pub trait TriggerCrud: Send + Sync + 'static { + for<'r> FromRow<'r, sqlx::postgres::PgRow> + Send + Sync - + Unpin; + + Unpin + // `'static` so the deployed trigger can be boxed into + // `WithDraftOverlay`'s erased-serde inner (it's an owned row). + + 'static; type TriggerConfig: Debug + DeserializeOwned @@ -77,6 +84,9 @@ pub trait TriggerCrud: Send + Sync + 'static { /// constant set by each trigger impl — it is never user-controllable. const TABLE_NAME: &'static str; const TRIGGER_TYPE: &'static str; + /// `UserDraftItemKind` for this trigger's per-user `draft` rows. Required (no + /// default) so a trigger that forgets it is a compile error, not a runtime panic. + const DRAFT_KIND: UserDraftItemKind; const SUPPORTS_SERVER_STATE: bool; const SUPPORTS_TEST_CONNECTION: bool; const ROUTE_PREFIX: &'static str; @@ -127,6 +137,11 @@ pub trait TriggerCrud: Send + Sync + 'static { &Self::ROUTE_PREFIX[1..] } + /// Accessor for `DRAFT_KIND` used at the draft-lookup call sites. + fn user_draft_item_kind() -> UserDraftItemKind { + Self::DRAFT_KIND + } + async fn create_trigger( &self, db: &DB, @@ -349,11 +364,14 @@ pub trait TriggerCrud: Send + Sync + 'static { count } + /// `authed_email = Some` adds the per-user `is_draft` flag (scalar EXISTS); + /// `None` (e.g. workspace export) leaves it omitted. async fn list_triggers( &self, tx: &mut PgConnection, workspace_id: &str, query: Option<&StandardTriggerQuery>, + authed_email: Option<&str>, ) -> Result> { let mut fields = vec![ "workspace_id", @@ -381,6 +399,19 @@ pub trait TriggerCrud: Send + Sync + 'static { .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&workspace_id)); + if let Some(email) = authed_email { + // SAFETY: interpolated TABLE_NAME and draft kind are compile-time constants; email is bound. + sqlb.field( + &format!( + "EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = {t}.workspace_id \ + AND draft.path = {t}.path AND draft.typ = '{k}' AND draft.email = ?) as is_draft", + t = Self::TABLE_NAME, + k = Self::user_draft_item_kind().as_str(), + ) + .bind(&email), + ); + } + if let Some(query) = query { let (per_page, offset) = paginate(Pagination { per_page: query.per_page, page: query.page }); @@ -563,15 +594,74 @@ async fn list_triggers( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; - let triggers = handler - .list_triggers(&mut *tx, &workspace_id, Some(&query)) + let mut triggers = handler + .list_triggers(&mut *tx, &workspace_id, Some(&query), Some(&authed.email)) .await?; tx.commit().await?; + // Append the authed user's draft-only triggers of this kind; see scripts.rs. + // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind + // config can deviate, so drop a row on deserialize failure rather than fail the list. + if query.include_draft_only.unwrap_or(false) + && !authed.is_operator + && query.page.unwrap_or(0) == 0 + && query.path.is_none() + && query.is_flow.is_none() + && query.path_start.is_none() + && query.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + ) + .await?; + + for row in draft_only_rows { + let created_at = row.created_at; + let v: serde_json::Value = match serde_json::from_str(row.value.0.get()) { + Ok(v) => v, + Err(_) => continue, + }; + let serde_json::Value::Object(mut map) = v else { + continue; + }; + // Fill operational fields the editor draft omits so the merged JSON matches + // `Trigger`'s flattened shape (mode derived from `enabled`). + map.insert( + "workspace_id".into(), + serde_json::Value::String(workspace_id.clone()), + ); + map.insert("edited_by".into(), serde_json::Value::String(String::new())); + if let Ok(at) = serde_json::to_value(&created_at) { + map.insert("edited_at".into(), at); + } + map.entry("permissioned_as") + .or_insert(serde_json::Value::String(String::new())); + map.entry("extra_perms").or_insert(serde_json::Value::Null); + if !map.contains_key("mode") { + let enabled = map.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + map.insert( + "mode".into(), + serde_json::Value::String(if enabled { "enabled" } else { "disabled" }.into()), + ); + } + map.insert("draft_only".into(), serde_json::Value::Bool(true)); + // Synthesized rows are the authed user's draft. + map.insert("is_draft".into(), serde_json::Value::Bool(true)); + match serde_json::from_value::(serde_json::Value::Object(map)) { + Ok(t) => triggers.push(t), + Err(_) => continue, + } + } + } + Ok(Json(triggers)) } @@ -579,21 +669,41 @@ async fn get_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || { format!("{}:read:{}", T::scope_domain_name(), &path) })?; let mut tx = user_db.begin(&authed).await?; - let trigger = handler + let trigger_res = handler .get_trigger_by_path(&mut *tx, &workspace_id, path) - .await?; - + .await; tx.commit().await?; - Ok(Json(trigger)) + // Map "no deployed trigger" to `None` and let the shared choreography + // handle the draft overlay / draft-only fallback / 404. + let deployed = match trigger_res { + Ok(t) => Some(t), + Err(Error::NotFound(_)) => None, + Err(e) => return Err(e), + }; + + let overlay = overlay_or_draft_only( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + path, + q.get_draft, + deployed, + || Error::NotFound(format!("Trigger not found at path: {}", path)), + ) + .await?; + Ok(Json(overlay)) } async fn update_trigger( @@ -717,6 +827,19 @@ async fn update_trigger( tx.commit().await?; + // On rename the old-path draft orphans (no SQL FK); clear the deployer's own + // (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). See scripts.rs. + if path != new_path { + delete_own_draft_for_path( + &db, + &workspace_id, + T::user_draft_item_kind(), + path, + &authed.email, + ) + .await?; + } + Ok(format!("Trigger '{}' updated", path)) } @@ -724,6 +847,7 @@ async fn delete_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> Result { let path = path.to_path(); @@ -781,6 +905,9 @@ async fn delete_trigger( tx.commit().await?; + // Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?; + Ok(format!("Trigger '{}' deleted", path)) } diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 8b42c5b1f9..77d90bea20 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -27,6 +27,10 @@ pub struct StandardTriggerQuery { pub is_flow: Option, pub path_start: Option, pub label: Option, + /// When true, append per-user draft rows whose path has no + /// deployed trigger of this kind. Same gate as scripts/flows/apps: + /// non-operators, offset 0, no narrowing filters. + pub include_draft_only: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -42,6 +46,22 @@ pub struct BaseTrigger { pub extra_perms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when this row is a per-user draft with no deployed trigger + /// at the same path. Set by `list_triggers` when the response + /// includes synthesized draft-only rows (gated on + /// `include_draft_only`). Always `None`/omitted on deployed rows + /// fetched from the trigger table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// either layered over a deployed trigger (EXISTS subquery in the + /// list SQL) or a synthesized draft-only row. Drives the `*` suffix + /// on the trigger list pages. `None`/omitted for callers that list + /// without an authed context (e.g. workspace export). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -192,6 +212,7 @@ impl Default for StandardTriggerQuery { path_start: None, is_flow: None, label: None, + include_draft_only: None, } } } @@ -260,6 +281,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 100); assert_eq!(q.limit(), 50); @@ -274,6 +296,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 0); assert_eq!(q.limit(), 100); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 14e2a14fe6..6346fe9cce 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -30,8 +30,6 @@ pub struct Flow { pub schema: Option, pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, @@ -83,7 +81,9 @@ pub struct ListableFlow { pub archived: bool, pub extra_perms: serde_json::Value, pub starred: bool, - pub has_draft: bool, + /// `Some(true)` only on synthesised draft-only rows; `None` on deployed rows. + /// See ListableScript in scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -93,6 +93,20 @@ pub struct ListableFlow { pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this flow (draft-only or layered + /// over the deployed row). See ListableScript in scripts.rs. + #[serde(default)] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -107,7 +121,6 @@ pub struct NewFlow { #[serde(deserialize_with = "validate_flow_value")] pub value: Box, pub schema: Option, - pub draft_only: Option, pub tag: Option, pub dedicated_worker: Option, pub timeout: Option, diff --git a/backend/windmill-types/src/lib.rs b/backend/windmill-types/src/lib.rs index 4d9b96c2af..9144d61f89 100644 --- a/backend/windmill-types/src/lib.rs +++ b/backend/windmill-types/src/lib.rs @@ -19,6 +19,8 @@ pub mod schedule; pub mod scripts; #[cfg(not(target_arch = "wasm32"))] pub mod triggers; +#[cfg(not(target_arch = "wasm32"))] +pub mod user_drafts; /// Duplicated from windmill-common::worker::to_raw_value. /// windmill-types cannot depend on windmill-common (it would be circular). diff --git a/backend/windmill-types/src/s3.rs b/backend/windmill-types/src/s3.rs index 0ab1794d7e..5a7634043c 100644 --- a/backend/windmill-types/src/s3.rs +++ b/backend/windmill-types/src/s3.rs @@ -364,13 +364,11 @@ mod tests { assert_eq!(deserialized, S3Permission::READ | S3Permission::WRITE); // Unknown permissions are silently ignored - let deserialized: S3Permission = - serde_json::from_str("\"read,unknown,delete\"").unwrap(); + let deserialized: S3Permission = serde_json::from_str("\"read,unknown,delete\"").unwrap(); assert_eq!(deserialized, S3Permission::READ | S3Permission::DELETE); // All four permissions - let all: S3Permission = - serde_json::from_str("\"read,write,delete,list\"").unwrap(); + let all: S3Permission = serde_json::from_str("\"read,write,delete,list\"").unwrap(); assert_eq!( all, S3Permission::READ | S3Permission::WRITE | S3Permission::DELETE | S3Permission::LIST @@ -409,20 +407,15 @@ mod tests { ); // Region set, endpoint empty → use region - let with_region = S3Resource { - region: "ap-southeast-1".to_string(), - ..resource.clone() - }; + let with_region = S3Resource { region: "ap-southeast-1".to_string(), ..resource.clone() }; assert_eq!( with_region.endpoint_with_region_fallback(Some("ignored".to_string())), "s3.ap-southeast-1.amazonaws.com" ); // Endpoint set → return as-is - let with_endpoint = S3Resource { - endpoint: "custom.s3.endpoint.com".to_string(), - ..resource.clone() - }; + let with_endpoint = + S3Resource { endpoint: "custom.s3.endpoint.com".to_string(), ..resource.clone() }; assert_eq!( with_endpoint.endpoint_with_region_fallback(Some("ignored".to_string())), "custom.s3.endpoint.com" @@ -431,10 +424,8 @@ mod tests { #[test] fn test_lfs_methods_filesystem() { - let rules = vec![S3PermissionRule { - pattern: "**/*.csv".to_string(), - allow: S3Permission::READ, - }]; + let rules = + vec![S3PermissionRule { pattern: "**/*.csv".to_string(), allow: S3Permission::READ }]; let lfs = LargeFileStorage::FilesystemStorage(FilesystemStorage { root_path: "/data/workspace".to_string(), public_resource: Some(true), diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index a777d69790..5e5c7f313e 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -323,7 +323,7 @@ pub fn id_to_codebase_info(id: &str) -> CodebaseInfo { pub const SCRIPT_COLUMNS: &str = concat!( "workspace_id, hash, path, parent_hashes, summary, description, content, ", "created_by, created_at, archived, schema, deleted, is_template, extra_perms, ", - "lock, lock_error_logs, language, kind, tag, draft_only, envs, ", + "lock, lock_error_logs, language, kind, tag, envs, ", "dedicated_worker, ws_error_handler_muted, priority, cache_ttl, cache_ignore_s3_path, ", "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, ", @@ -355,8 +355,6 @@ pub struct Script { pub kind: ScriptKind, pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub envs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, @@ -449,8 +447,10 @@ pub struct ListableScript { pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_draft: Option, + /// `Some(true)` only on rows synthesised from the `draft` table (never-deployed + /// items the user owns a draft for); `None` on deployed rows. Kept on the public + /// response so consumers checking `draft_only === true` keep working. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, pub has_deploy_errors: bool, @@ -465,6 +465,22 @@ pub struct ListableScript { pub kind: ScriptKind, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `true` when this entry is the authed user's draft — draft-only, or a deployed + /// row the user has saved a draft on top of. Distinguishes user state from team state. + #[serde(skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path, so the home list shows a meaningful name over the + /// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use + /// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an + /// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners (`{ username }`, `None` for the legacy NULL-email row), + /// driving the home-page avatar circles. `None` when no drafts; never an empty array. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -516,7 +532,6 @@ pub struct NewScript { pub language: ScriptLang, pub kind: Option, pub tag: Option, - pub draft_only: Option, pub envs: Option>, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, @@ -574,7 +589,6 @@ impl Hash for NewScript { self.language.hash(state); self.kind.hash(state); self.tag.hash(state); - self.draft_only.hash(state); self.envs.hash(state); self.concurrency_settings.hash(state); self.debouncing_settings.hash(state); diff --git a/backend/windmill-types/src/user_drafts.rs b/backend/windmill-types/src/user_drafts.rs new file mode 100644 index 0000000000..30740fc561 --- /dev/null +++ b/backend/windmill-types/src/user_drafts.rs @@ -0,0 +1,18 @@ +//! Shared types for the per-user draft surface. +//! +//! Mirrors the `OtherDraftUser` type in `windmill-common::user_drafts` — +//! kept here so `windmill-types` row structs (ListableScript / ListableFlow / +//! ListableApp) can expose a typed `draft_users` field without taking a +//! dependency on `windmill-common`. The two structs serialize identically, +//! so the frontend doesn't notice. + +use serde::{Deserialize, Serialize}; + +/// One workspace user (or the legacy NULL-email row) with a per-user draft +/// at a given path. Used by the home-page list endpoints to feed the +/// avatar-circles inside the Draft badge. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DraftUserRef { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 1071331822..d7f61151e0 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6952,6 +6952,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' azure_resource_path: type: string azure_mode: @@ -7037,6 +7057,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' local_part: type: string workspaced_local_part: @@ -7102,6 +7142,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' gcp_resource_path: type: string description: Path to the GCP resource containing service account credentials for @@ -7203,6 +7263,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' route_path: type: string description: The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). @@ -7337,6 +7417,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' kafka_resource_path: type: string description: Path to the Kafka resource containing connection configuration @@ -7440,6 +7540,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' mqtt_resource_path: type: string description: Path to the MQTT resource containing broker connection configuration @@ -7534,6 +7654,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' nats_resource_path: type: string description: Path to the NATS resource containing connection configuration @@ -7615,6 +7755,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' postgres_resource_path: type: string description: Path to the PostgreSQL resource containing connection configuration @@ -7797,6 +7957,22 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + schedule at the same path. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' inherited_labels: type: array items: @@ -7828,6 +8004,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -7908,6 +8104,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' url: type: string description: The WebSocket URL to connect to (can be a static URL or computed diff --git a/frontend/src/lib/coalescingRunner.svelte.ts b/frontend/src/lib/coalescingRunner.svelte.ts new file mode 100644 index 0000000000..0f687d4829 --- /dev/null +++ b/frontend/src/lib/coalescingRunner.svelte.ts @@ -0,0 +1,118 @@ +/** + * Per-key coalescing async runner: at most one task running and one pending per + * key. `submit` runs `fn` now if idle, else REPLACES the pending task (the + * displaced one never runs); the pending task starts when the running one + * settles. Collapses bursts of "save latest" to the in-flight call plus the + * most recent. Keys are independent. + */ +import { SvelteSet } from 'svelte/reactivity' + +export type CoalescingTask = () => T | Promise + +/** Rejection reason for a `submitAndWait` (or `cancel`-dropped) task discarded + * before it ran. Only awaiters see it, not fire-and-forget `submit` callers. */ +export class CoalescingDisplacedError extends Error { + constructor() { + super('coalescingRunner: pending task displaced before it could run') + this.name = 'CoalescingDisplacedError' + } +} + +export type CoalescingKeyedRunner = { + /** Fire-and-forget schedule per the policy above. */ + submit(key: string, fn: CoalescingTask): void + /** Like `submit`, but the promise resolves/rejects with `fn`'s outcome, or + * rejects with `CoalescingDisplacedError` if dropped before running. */ + submitAndWait(key: string, fn: CoalescingTask): Promise + /** Drop the pending task for `key` (returns whether there was one). Can't + * abort an in-flight task. A dropped `submitAndWait` rejects with + * `CoalescingDisplacedError`. */ + cancel(key: string): boolean + /** Reactively whether `key`'s chain is running (SvelteSet-backed). */ + isRunning(key: string): boolean +} + +type PendingTask = { + fn: CoalescingTask + resolve?: (value: unknown) => void + reject?: (reason: unknown) => void +} + +type Entry = { pending: PendingTask | undefined } + +/** + * @example + * runner.submit('k', f) // runs immediately + * runner.submit('k', g) // f running: g pending + * runner.submit('k', h) // g discarded, h pending; h runs once f settles + */ +export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { + const state = new Map() + // Reactive mirror of keys with a running chain, kept in lock-step with + // `state` (SvelteSet for per-key `isRunning` subscriptions). + const runningKeys = new SvelteSet() + + async function chain(key: string, first: PendingTask): Promise { + let current: PendingTask | undefined = first + while (current) { + try { + const result = await current.fn() + current.resolve?.(result) + } catch (e) { + // Don't kill the chain on failure — later submissions must + // still run. Awaiters get the error via their promise; + // fire-and-forget callers get a console.error. + if (current.reject) current.reject(e) + else console.error('coalescingRunner: task failed', e) + } + const entry = state.get(key)! + current = entry.pending + entry.pending = undefined + } + state.delete(key) + runningKeys.delete(key) + } + + /** Set `task` pending for `key`, displacing (and rejecting) any prior + * pending. If the key is idle, start the chain. */ + function setOrDisplace(key: string, task: PendingTask): void { + const entry = state.get(key) + if (entry) { + entry.pending?.reject?.(new CoalescingDisplacedError()) + entry.pending = task + return + } + state.set(key, { pending: undefined }) + runningKeys.add(key) + void chain(key, task) + } + + function submit(key: string, fn: CoalescingTask): void { + setOrDisplace(key, { fn }) + } + + function submitAndWait(key: string, fn: CoalescingTask): Promise { + return new Promise((resolve, reject) => { + setOrDisplace(key, { + fn: fn as CoalescingTask, + resolve: resolve as (v: unknown) => void, + reject + }) + }) + } + + function cancel(key: string): boolean { + const entry = state.get(key) + if (!entry?.pending) return false + const dropped = entry.pending + entry.pending = undefined + dropped.reject?.(new CoalescingDisplacedError()) + return true + } + + function isRunning(key: string): boolean { + return runningKeys.has(key) + } + + return { submit, submitAndWait, cancel, isRunning } +} diff --git a/frontend/src/lib/coalescingRunner.test.ts b/frontend/src/lib/coalescingRunner.test.ts new file mode 100644 index 0000000000..49cca4688e --- /dev/null +++ b/frontend/src/lib/coalescingRunner.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest' +import { createCoalescingKeyedRunner, CoalescingDisplacedError } from './coalescingRunner.svelte' + +/** A promise plus its resolve/reject, so a test can decide exactly when a + * task settles. */ +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createCoalescingKeyedRunner', () => { + it('runs a submitted task immediately when the key is idle', () => { + const runner = createCoalescingKeyedRunner() + const fn = vi.fn(() => Promise.resolve()) + runner.submit('k', fn) + expect(fn).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(true) + }) + + it('clears isRunning once the in-flight task settles', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(runner.isRunning('k')).toBe(false) + }) + + it('coalesces a burst down to in-flight + latest-pending', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const f = vi.fn(() => d.promise) + const g = vi.fn(() => Promise.resolve()) + const h = vi.fn(() => Promise.resolve()) + + runner.submit('k', f) // runs now + runner.submit('k', g) // pending + runner.submit('k', h) // displaces g; h is the only pending + + expect(f).toHaveBeenCalledTimes(1) + expect(g).not.toHaveBeenCalled() + expect(h).not.toHaveBeenCalled() + + d.resolve() + await d.promise + await Promise.resolve() + await Promise.resolve() + + expect(g).not.toHaveBeenCalled() // displaced — never ran + expect(h).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(false) + }) + + it('keeps different keys independent', () => { + const runner = createCoalescingKeyedRunner() + const a = vi.fn(() => new Promise(() => {})) + const b = vi.fn(() => new Promise(() => {})) + runner.submit('a', a) + runner.submit('b', b) // different key → runs immediately too + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledTimes(1) + expect(runner.isRunning('a')).toBe(true) + expect(runner.isRunning('b')).toBe(true) + }) + + it('submitAndWait resolves with the task result', async () => { + const runner = createCoalescingKeyedRunner() + await expect(runner.submitAndWait('k', () => Promise.resolve(42))).resolves.toBe(42) + }) + + it('submitAndWait rejects with the task error', async () => { + const runner = createCoalescingKeyedRunner() + await expect( + runner.submitAndWait('k', () => Promise.reject(new Error('boom'))) + ).rejects.toThrow('boom') + }) + + it('rejects a displaced submitAndWait with CoalescingDisplacedError', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) // hold the key busy + const dropped = runner.submitAndWait('k', () => Promise.resolve('a')) // pending + runner.submit('k', () => Promise.resolve('b')) // displaces it + await expect(dropped).rejects.toBeInstanceOf(CoalescingDisplacedError) + d.resolve() + }) + + it('cancel drops the pending task and rejects its awaiter', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const pending = vi.fn(() => Promise.resolve()) + runner.submit('k', () => d.promise) // in flight + const awaited = runner.submitAndWait('k', pending) // pending + + expect(runner.cancel('k')).toBe(true) + await expect(awaited).rejects.toBeInstanceOf(CoalescingDisplacedError) + + d.resolve() + await d.promise + await Promise.resolve() + expect(pending).not.toHaveBeenCalled() // cancelled before it could run + }) + + it('cancel returns false when there is no pending task', () => { + const runner = createCoalescingKeyedRunner() + expect(runner.cancel('k')).toBe(false) + runner.submit('k', () => new Promise(() => {})) // running, nothing pending + expect(runner.cancel('k')).toBe(false) + }) + + it('does not abort the in-flight task on cancel', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const inflight = vi.fn(() => d.promise) + runner.submit('k', inflight) + runner.cancel('k') // only affects pending; nothing pending here + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(inflight).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte new file mode 100644 index 0000000000..168f68ebd7 --- /dev/null +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -0,0 +1,339 @@ + + +
+ {#if flashActive} + + {#key flashKey} + + {/key} + {/if} + + {#snippet trigger()} +
+ {#if syncState === 'saving' || syncState === 'pending'} + + {:else if syncState === 'failed'} + + {:else if !autosaveEnabled} + + + {:else} + + {/if} +
+ {/snippet} + + {#snippet content()} +
+ {#if syncState === 'failed'} +
+

Save failed

+ {#if failureMessage} +
{failureMessage}
+ {/if} +
+ {/if} + {#if autosaveEnabled} +

+ All changes are saved as a draft on the server. The draft is per-user — your teammates' + editors keep their own. +

+ {:else} +

+ Auto-save is off — changes only persist when you press Ctrl/Cmd+S. The draft is per-user + — your teammates' editors keep their own. +

+ {/if} + { + UserDraftDbSyncer.autosaveEnabled = e.detail + }} + /> + {#if othersDraftsCount > 0} +
+

+ Other users are working on this {kindLabel}. +

+ +
+ {/if} + {#if showResetAction} + + {/if} +
+ {/snippet} +
+ {#if label} + {label} + {/if} +
+ + diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 386007317c..264efb73a0 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -13,6 +13,7 @@ import { sendUserToast } from '$lib/toast' import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy' import { type DraftItem } from '$lib/workspaceDrafts.svelte' + import type { Kind as LayoutKind } from '$lib/utils_deployable' interface Props { currentWorkspaceId: string @@ -52,7 +53,11 @@ }: Props = $props() type Row = { - kind: DraftItem['kind'] + /** The deploy layout's `Kind` naming (`http_trigger`, `schedule`, + * ...) — the layout reads `item.kind` for the row icon. Our + * `UserDraftItemKind` naming lives in `draftKind`. */ + kind: LayoutKind + draftKind: DraftItem['kind'] path: string summary?: string draft_only: boolean @@ -63,11 +68,43 @@ return `${kind}:${path}` } + // UserDraftItemKind → the deploy layout's Kind union (drives row icons). + // Trigger kinds swap the prefix to a suffix; kinds the layout doesn't + // know (webhook, native triggers) borrow the generic 'trigger' icon. + function toLayoutKind(kind: DraftItem['kind']): LayoutKind { + if (kind === 'trigger_schedule') return 'schedule' + if (kind === 'trigger_default_email') return 'email_trigger' + if (kind.startsWith('trigger_')) { + const candidate = `${kind.slice('trigger_'.length)}_trigger` + const known = [ + 'http_trigger', + 'websocket_trigger', + 'kafka_trigger', + 'nats_trigger', + 'postgres_trigger', + 'mqtt_trigger', + 'sqs_trigger', + 'gcp_trigger', + 'azure_trigger', + 'email_trigger' + ] + return (known.includes(candidate) ? candidate : 'trigger') as LayoutKind + } + return kind as LayoutKind + } + // The list (and the Draft Count) come from the shared Workspace Drafts module, // owned by the page and passed in via `draftItems`; deploy/discard invalidate // that resource, so the list refetches and deployed items drop off without a // manual reload here. - const items: Row[] = $derived(draftItems.map((d) => ({ ...d, key: getItemKey(d.kind, d.path) }))) + const items: Row[] = $derived( + draftItems.map((d) => ({ + ...d, + key: getItemKey(d.kind, d.path), + kind: toLayoutKind(d.kind), + draftKind: d.kind + })) + ) // The Draft Items list only carries the *deployed* summary, so the draft's // (new) display name isn't known yet. Fetch each item's draft blob once and @@ -84,13 +121,22 @@ if (summaryCache[item.key]) return summaryCache[item.key] = { loading: true } try { - const r = (await (item.kind === 'script' - ? ScriptService.getScriptByPathWithDraft({ workspace: currentWorkspaceId, path: item.path }) - : item.kind === 'flow' - ? FlowService.getFlowByPathWithDraft({ workspace: currentWorkspaceId, path: item.path }) - : AppService.getAppByPathWithDraft({ + const r = (await (item.draftKind === 'script' + ? ScriptService.getScriptByPath({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true + }) + : item.draftKind === 'flow' + ? FlowService.getFlowByPath({ workspace: currentWorkspaceId, - path: item.path + path: item.path, + getDraft: true + }) + : AppService.getAppByPath({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true }))) as any summaryCache[item.key] = { deployed: r.summary, @@ -107,7 +153,12 @@ const current = items untrack(() => { for (const item of current) { - if (!item.draft_only && !item.raw_app && !summaryCache[item.key]) { + if ( + !item.draft_only && + !item.raw_app && + ['script', 'flow', 'app'].includes(item.draftKind) && + !summaryCache[item.key] + ) { void fetchDraftSummary(item) } } @@ -192,10 +243,10 @@ async function showDiff(item: Row) { if (!diffDrawer) return const reqId = ++diffRequestId - isFlow = item.kind === 'flow' + isFlow = item.draftKind === 'flow' diffDrawer.openDrawer() const { deployed, draft } = await getDraftDiffValues( - item.kind, + item.draftKind, item.path, currentWorkspaceId, item.draft_only @@ -220,7 +271,7 @@ for (const item of toDeploy) { deploymentStatus[item.key] = { status: 'loading' } const res = await deployDraft( - item.kind, + item.draftKind, item.path, currentWorkspaceId, item.draft_only, @@ -249,7 +300,7 @@ const item = discardTarget discardTarget = undefined if (!item) return - const res = await discardDraft(item.kind, item.path, currentWorkspaceId, item.draft_only) + const res = await discardDraft(item.draftKind, item.path, currentWorkspaceId, item.draft_only) if (res.success) { sendUserToast(item.draft_only ? `Deleted ${item.path}` : `Discarded draft of ${item.path}`) // discardDraft invalidated the Draft list; refresh the fork comparison. @@ -260,13 +311,43 @@ } // Editor URL for a draft item, scoped to the current workspace. Raw apps live - // under a different editor route, so map their kind accordingly. + // under a different editor route, so map their kind accordingly. Kinds whose + // editor is a drawer on a list page (variables, resources, schedules, + // triggers) link to that page with the item path as the hash anchor. + const LIST_PAGE_FOR_KIND: Partial> = { + variable: '/variables', + resource: '/resources', + trigger_schedule: '/schedules', + trigger_http: '/routes', + trigger_websocket: '/websocket_triggers', + trigger_postgres: '/postgres_triggers', + trigger_kafka: '/kafka_triggers', + trigger_nats: '/nats_triggers', + trigger_mqtt: '/mqtt_triggers', + trigger_sqs: '/sqs_triggers', + trigger_gcp: '/gcp_triggers', + trigger_azure: '/azure_triggers', + trigger_email: '/email_triggers' + } function draftEditUrl(d: Row): string | undefined { + const listPage = LIST_PAGE_FOR_KIND[d.draftKind] + if (listPage) { + return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}` + } return editUrlFor( - { kind: d.raw_app ? 'raw_app' : d.kind, path: d.path } as unknown as WorkspaceItemDiff, + { kind: d.raw_app ? 'raw_app' : d.draftKind, path: d.path } as unknown as WorkspaceItemDiff, currentWorkspaceId ) } + + // Human label for the kind badge on each row — without it a variable + // draft and a script draft at the same path are indistinguishable. + function kindLabel(kind: Row['draftKind']): string { + if (kind === 'raw_app') return 'app' + if (kind === 'trigger_schedule') return 'schedule' + if (kind.startsWith('trigger_')) return `${kind.slice('trigger_'.length)} trigger` + return kind + }
@@ -335,6 +416,7 @@ {#snippet itemActions(item)} {@const draftItem = item as unknown as Row} + {kindLabel(draftItem.draftKind)} {#if draftItem.draft_only} New {/if} diff --git a/frontend/src/lib/components/ContentSearchInner.svelte b/frontend/src/lib/components/ContentSearchInner.svelte index 8b20855193..d3030254ad 100644 --- a/frontend/src/lib/components/ContentSearchInner.svelte +++ b/frontend/src/lib/components/ContentSearchInner.svelte @@ -363,11 +363,7 @@ > Open - {/snippet} diff --git a/frontend/src/lib/components/DiffDrawer.svelte b/frontend/src/lib/components/DiffDrawer.svelte index 0f89cfc3dd..58e245d74e 100644 --- a/frontend/src/lib/components/DiffDrawer.svelte +++ b/frontend/src/lib/components/DiffDrawer.svelte @@ -87,7 +87,7 @@ | { mode: 'normal' deployed: Value - draft: Value | undefined + draft?: Value | undefined current: Value defaultDiffType?: 'deployed' | 'draft' button?: { text: string; onClick: () => void } diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index a5bcf73df5..99c5af259b 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -1,29 +1,282 @@ -{#if has_draft} - {#if draft_only} - - {#snippet text()} - Never deployed and is only a draft - {/snippet} - Draft only - - {:else} - - {#snippet text()} - Is deployed and has a draft - {/snippet} - +Draft - - {/if} +{#if showBadge} + + {#snippet trigger()} + + {#if orderedUsers.length > 0} + + + {#each visibleUsers as u, i (i)} + + {initials(u)} + + {/each} + {#if overflowCount > 0} + + +{overflowCount} + + {/if} + + {/if} + {draft_only ? 'Draft only' : 'Draft'} + + {/snippet} + {#snippet content()} +
+

+ {#if draft_users.length > 0} + {draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'} + {:else if draft_only} + Never deployed and is only a draft + {:else} + Is deployed and has a draft + {/if} + + {#if onlyOwnDraft} +
+ Only you can see this {kindLabel}. + {/if} +

+ {#if draft_users.length > 0} +
    + {#each orderedUsers as u, i (i)} + {@const isSelf = !!currentUsername && u.username === currentUsername} +
  • + + {initials(u)} + + + {fullLabel(u)}{isSelf ? ' (you)' : ''} + + {#if actionsEnabled && !isSelf} + + + {#if !$userStore?.operator} + + {/if} + {/if} +
  • + {/each} +
+ {/if} +
+ {/snippet} +
{/if} + + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 698e799aad..74b7a8e8ec 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -136,7 +136,17 @@ lineNumbersMinChars?: number files?: Record | undefined extraLib?: string | undefined + /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. + * Each keystroke schedules (or reschedules) an `updateCode` call this + * far in the future. */ changeTimeout?: number + /** Hard ceiling (ms) on how long `updateCode` can be deferred while + * the user is typing continuously — measured from the FIRST + * keystroke of the burst (the leading fire). Without this cap, + * uninterrupted typing would hold the bindable `code` prop stale + * indefinitely and downstream consumers (autosave, lint, live + * preview) would never see the latest text. */ + maxChangeTimeout?: number loadAsync?: boolean key?: string | undefined class?: string | undefined @@ -177,6 +187,7 @@ files = {}, extraLib = undefined, changeTimeout = 500, + maxChangeTimeout = 1000, loadAsync = false, key = undefined, class: clazz = undefined, @@ -417,6 +428,22 @@ dispatch('change', ncode) } + /** Force-materialize the latest Monaco content into the bindable + * `code` prop right now, bypassing the trailing debounce. Use for + * explicit "save now" shortcuts (Ctrl/Cmd+S) — without this, anything + * the user typed within the last `changeTimeout` ms is still sitting + * in Monaco's buffer and downstream consumers (autosave, lint) won't + * see it. Clears the chain state so the next keystroke after this + * flush is a fresh leading fire. */ + export function flushPendingChanges(): void { + if (timeoutModel !== undefined) { + clearTimeout(timeoutModel) + timeoutModel = undefined + } + changeChainStart = undefined + updateCode() + } + export function append(code: string): void { if (editor) { const lineCount = editor.getModel()?.getLineCount() || 0 @@ -1327,6 +1354,10 @@ } let timeoutModel: number | undefined = undefined + /** Wall-clock start (ms) of the current debounce chain. Reset whenever + * the trailing fire lands — so a typing burst → pause → typing burst + * gets a fresh leading fire instead of inheriting the previous cap. */ + let changeChainStart: number | undefined = undefined async function loadMonaco() { setMonacoTypescriptOptions() console.log('path', uri) @@ -1433,10 +1464,29 @@ let ataModel: number | undefined = undefined editor?.onDidChangeModelContent((event) => { - timeoutModel && clearTimeout(timeoutModel) - timeoutModel = setTimeout(() => { + // Leading fire on the first keystroke of a burst: every + // downstream consumer (autosave's 1.5s debouncer, the + // `bind:code` chain, change listeners) sees text within the + // same tick instead of after `changeTimeout` ms of silence. + // Subsequent keystrokes within the burst are trailing-only + // (debounced by `changeTimeout`), with a hard ceiling at + // `chainStart + maxChangeTimeout` so continuous typing still + // materializes at least once per `maxChangeTimeout` window. + const now = Date.now() + if (changeChainStart === undefined) { updateCode() - }, changeTimeout) + changeChainStart = now + } + timeoutModel && clearTimeout(timeoutModel) + const fireAt = Math.min(now + changeTimeout, changeChainStart + maxChangeTimeout) + timeoutModel = setTimeout( + () => { + updateCode() + timeoutModel = undefined + changeChainStart = undefined + }, + Math.max(0, fireAt - now) + ) ataModel && clearTimeout(ataModel) ataModel = setTimeout(() => { @@ -1479,6 +1529,12 @@ editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () { updateCode() shouldBindKey && format && format() + // Monaco swallows the keydown (addCommand prevents default and + // stops propagation), so page-level Ctrl/Cmd+S handlers never + // see it. Re-broadcast as a window event so editors that flush + // a draft on the shortcut (raw apps) can react regardless of + // which Monaco has focus. + window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')) }) editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () { @@ -1788,6 +1844,7 @@ resultCollectionCompletor && resultCollectionCompletor.dispose() preprocessorCompletor && preprocessorCompletor.dispose() timeoutModel && clearTimeout(timeoutModel) + changeChainStart = undefined loadTimeout && clearTimeout(loadTimeout) aiChatEditorHandler?.clear() absolutePathExtraLibs.forEach((d) => d.dispose()) diff --git a/frontend/src/lib/components/EncryptedDraftField.svelte b/frontend/src/lib/components/EncryptedDraftField.svelte new file mode 100644 index 0000000000..645cf27fae --- /dev/null +++ b/frontend/src/lib/components/EncryptedDraftField.svelte @@ -0,0 +1,48 @@ + + + + + {#snippet trigger()} + + {/snippet} + {#snippet content()} +
+

+ This secret was encrypted when your draft was saved and cannot be loaded back. The last + saved value will be used as-is when you save. +

+ {#if !disabled} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 24c0071a5e..43e8fe7f42 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -2,7 +2,6 @@ import { FlowService, type Flow, - DraftService, type PathScript, type OpenFlow, type InputTransform, @@ -13,7 +12,6 @@ import { initHistory, redo, undo } from '$lib/history.svelte' import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores' import { - cleanValueProperties, generateRandomString, orderedJsonStringify, readFieldsRecursively, @@ -25,11 +23,12 @@ type Value } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { Drawer } from '$lib/components/common' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' - import { createRawSnippet, onMount, setContext, untrack } from 'svelte' + import { createRawSnippet, setContext, untrack } from 'svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -46,7 +45,6 @@ import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { cleanFlow } from './flows/utils.svelte' import { - Save, DiffIcon, HistoryIcon, FileJson, @@ -71,18 +69,15 @@ import { tutorialsToDo } from '$lib/stores' import { getTutorialIndex } from '$lib/tutorials/config' import EditorHeader from './EditorHeader.svelte' + import AutosaveIndicator from './AutosaveIndicator.svelte' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte' import { type TriggerContext, type ScheduleTrigger } from './triggers' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' - import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils' - import { - deployTriggers, - filterDraftTriggers, - handleSelectTriggerFromKind - } from './triggers/utils' + import type { Trigger } from './triggers/utils' + import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils' import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte' import { Triggers } from './triggers/triggers.svelte' import { StepsInputArgs } from './flows/stepsInputArgs.svelte' @@ -118,22 +113,21 @@ disabledFlowInputs = false, savedPrimarySchedule = undefined, version = undefined, - setSavedraftCb = undefined, draftTriggersFromUrl = undefined, selectedTriggerIndexFromUrl = undefined, children, loadedFromHistoryFromUrl, noInitial = false, liveEditorDraftStoragePath = undefined, - onSaveInitial, - onSaveDraft, onDeploy, onDeployError, onDetails, - onSaveDraftError, - onSaveDraftOnlyAtNewPath, onHistoryRestore, onNavigate, + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts, onTestJob }: FlowBuilderProps = $props() @@ -262,129 +256,28 @@ } let loadingSave = $state(false) - let loadingDraft = $state(false) - export async function saveDraft(forceSave = false): Promise { - withAIChangesWarning(async () => { - await saveDraftInternal(forceSave) + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Unlike ScriptBuilder we don't have a direct + // Monaco ref to flush — any focused module Monaco's pending text is + // constrained by the editor's own ~1s max-wait cap, so the flush + // here picks up whatever's already in `pendingSaveOpts`. Worst case + // the user's very last keystroke (<1s ago) isn't in this POST and + // follows in the next autosave round. + // + // No toast — the AutosaveIndicator narrates the flush (Saving... → + // Saved / Save failed). A toast here would also lie on network + // failure: `flush` never rejects (postSave catches and routes errors + // to the failures map), so the success branch fired regardless. + export async function saveDraft(): Promise { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'flow', + path: liveEditorDraftStoragePath }) } - async function saveDraftInternal(forceSave = false): Promise { - if (!newFlow && !savedFlow) { - return - } - - if (savedFlow) { - const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow) - const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot()) - const current = cleanValueProperties( - $state.snapshot({ - ...flowStore.val, - path: $pathStore, - draft_triggers: currentDraftTriggers - }) - ) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraftInternal(true) - } - } - ]) - return - } - } - loadingDraft = true - try { - const flow = cleanFlow(flowStore.val) - if (newFlow || savedFlow?.draft_only) { - if (savedFlow?.draft_only) { - await FlowService.deleteFlowByPath({ - workspace: $workspaceStore!, - path: initialPath, - keepCaptures: true - }) - } - if (!initialPath || $pathStore != initialPath) { - await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, - path: initialPath || fakeInitialPath, - requestBody: { - new_path: $pathStore - }, - runnableKind: 'flow' - }) - } - await FlowService.createFlow({ - workspace: $workspaceStore!, - requestBody: { - path: $pathStore, - summary: flow.summary ?? '', - description: flow.description ?? '', - value: flow.value, - schema: flow.schema, - tag: flow.tag, - draft_only: true, - ws_error_handler_muted: flow.ws_error_handler_muted, - visible_to_runner_only: flow.visible_to_runner_only, - on_behalf_of_email: flow.on_behalf_of_email, - labels: (flow as any).labels - } - }) - } - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath, - typ: 'flow', - value: { - ...flow, - path: $pathStore, - draft_triggers: triggersState.getDraftTriggersSnapshot() - } - } - }) - - savedFlow = { - ...(newFlow || savedFlow?.draft_only - ? { - ...structuredClone($state.snapshot(flowStore.val)), - path: $pathStore, - draft_only: true - } - : savedFlow), - draft: { - ...structuredClone($state.snapshot(flowStore.val)), - path: $pathStore, - draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) - } - } as FlowWithDraftAndDraftTriggers - - let savedAtNewPath = false - if (newFlow) { - onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' }) - } else if (savedFlow?.draft_only && $pathStore !== initialPath) { - savedAtNewPath = true - initialPath = $pathStore - onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' }) - // this is so we can use the flow builder outside of sveltekit - } - onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow }) - sendUserToast('Saved as draft') - } catch (error) { - sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true) - onSaveDraftError?.({ error }) - } - loadingDraft = false - } - - onMount(() => { - setSavedraftCb?.(() => saveDraft()) - }) - export function computeUnlockedSteps(flow: Flow) { return Object.fromEntries( getAllModules(flow.value.modules, flow.value.failure_module) @@ -401,7 +294,7 @@ async function handleSaveFlowInternal(deploymentMsg?: string) { await compareVersions() - if (onLatest || initialPath == '' || savedFlow?.draft_only) { + if (onLatest || initialPath == '' || newFlow) { // Handle directly await saveFlow(deploymentMsg) } else { @@ -678,7 +571,7 @@ [ { type: 'webhook', path: '', isDraft: false }, { type: 'default_email', path: '', isDraft: false }, - ...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? []) + ...(untrack(() => draftTriggersFromUrl) ?? []) ], untrack(() => selectedTriggerIndexFromUrl) ) @@ -707,10 +600,6 @@ $primaryScheduleStore, $userStore ) - - if (savedFlow && savedFlow.draft) { - savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers - } } function handleUndo() { @@ -812,7 +701,7 @@ }> = [] if (untrack(() => customUi).topBar?.extraDeployOptions != false) { - if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) { + if (!newFlow) { dropdownItems.push({ label: 'Exit & see details', // Use the deployed path, not the live `$pathStore` — the latter @@ -846,7 +735,6 @@ diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedFlow, - draft: savedFlow?.draft, current: { ...currentFlow, path: $pathStore, @@ -891,25 +779,7 @@ const mod = isMac() ? '⌘' : 'Ctrl+' function getMoreItems(): Item[] { - // When the top bar is compact, fold the inline Diff + Save draft buttons - // in here so they stay reachable. Save draft keeps its keyboard shortcut. - const compactExtras: Item[] = compactTopbar - ? [ - ...(customUi?.topBar?.draft !== false - ? [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: (!newFlow && !savedFlow) || loading - } - ] - : []) - ] - : [] return [ - ...compactExtras, ...baseMenuItems, { displayName: 'Undo', @@ -917,7 +787,7 @@ action: () => handleUndo(), disabled: $history.index === 0, shortcut: `${mod}Z`, - separatorTop: compactExtras.length > 0 || baseMenuItems.length > 0 + separatorTop: baseMenuItems.length > 0 }, { displayName: 'Redo', @@ -1030,17 +900,7 @@ ] } - function handleDeployTrigger(trigger: Trigger) { - const { id, path, type } = trigger - //Update the saved flow to remove the draft trigger that is deployed - if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) { - const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter( - (t) => t.id !== id || t.path !== path || t.type !== type - ) - savedFlow.draft.draft_triggers = - newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined - } - } + function handleDeployTrigger(_trigger: Trigger) {} let forceTestTab: Record = $state({}) let highlightArg: Record = $state({}) @@ -1070,6 +930,29 @@ if (p) untrack(() => ($pathStore = p)) }) + // Persist the user-typed path into the draft JSON as `draft_path` + // when it differs from the deployed/seeded `flow.path`. The Path + // widget binds `$pathStore` one-way to the popover input — without + // this, the friendly auto-name on `/flows/add` and any in-place + // rename never reach the autosaved Flow, so the home-list draft row + // kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop + // the field once it matches the baseline again so it doesn't + // linger after a revert; deploy clears the whole draft, so the + // field naturally disappears post-deploy too. + $effect(() => { + const typed = $pathStore + const baseline = (flowStore.val as Flow | undefined)?.path ?? '' + const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined + if (!flow) return + untrack(() => { + if (typed && typed !== baseline) { + flow.draft_path = typed + } else if (flow.draft_path !== undefined) { + delete (flow as any).draft_path + } + }) + }) + $effect.pre(() => { selectedId && untrack(() => select(selectedId)) }) @@ -1193,7 +1076,7 @@ bind:clientWidth={topbarWidth} class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative" > -
+
onNavigate?.(item)} /> + {#if $workspaceStore && liveEditorDraftStoragePath !== undefined} + + {/if}
{#if $enterpriseLicense && !newFlow} @@ -1219,9 +1114,11 @@ variant="default" unifiedSize="md" on:click={() => openDiffDrawer()} - disabled={!savedFlow} + disabled={!savedFlow || newFlow} iconOnly={compactTopbar} - title="Diff" + title={newFlow + ? 'Deploy this flow once to compare against the deployed version' + : 'Diff'} startIcon={{ icon: DiffIcon }} > Diff @@ -1230,19 +1127,6 @@ {#if !compactTopbar} {@render previewButtons()} {/if} - {#if customUi?.topBar?.draft !== false && !compactTopbar} - - {/if} await handleSaveFlow(detail)} diff --git a/frontend/src/lib/components/LocalDraftBanner.svelte b/frontend/src/lib/components/LocalDraftBanner.svelte index 2fb2de4c11..4aa2206f5b 100644 --- a/frontend/src/lib/components/LocalDraftBanner.svelte +++ b/frontend/src/lib/components/LocalDraftBanner.svelte @@ -2,7 +2,12 @@ import { Button } from '$lib/components/common' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import { classes } from '$lib/components/common/alert/model' - import { type Value } from '$lib/utils' + import { + cleanValueProperties, + orderedYamlStringify, + replaceFalseWithUndefined, + type Value + } from '$lib/utils' import { AlertCircle, Diff } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -31,6 +36,34 @@ title = 'Deployed <> Local changes' }: Props = $props() + /** Same cleaning + YAML serialization the DiffDrawer applies before + * comparing. Without it the banner would fire on differences the + * drawer treats as no-op (toggle defaults, `false ↔ undefined`, + * key ordering noise) — exactly the case where the user clicks + * "Show diff" and sees the "No changes detected" empty state. */ + function diffKey(value: unknown): string { + try { + return orderedYamlStringify(cleanValueProperties(replaceFalseWithUndefined(value as Value))) + } catch { + return '' + } + } + + // Suppress the banner when: + // • There's no deployed baseline (brand-new entity — "Show diff" + // would early-return and "Discard" is semantically backwards), + // OR + // • Deployed and current are equal under the DiffDrawer's own + // comparison. The callers' `show` is a coarser "form differs + // from baseline" check that can stale-fire after a save lands + // or when `false`/`undefined` toggle noise flips a field. + let visible = $derived.by(() => { + if (!show) return false + const deployed = getDeployed() + if (deployed == null) return false + return diffKey(deployed) !== diffKey(getCurrent()) + }) + let diffDrawer: DiffDrawer | undefined = $state() function showDiff() { @@ -66,7 +99,7 @@ -{#if show} +{#if visible}
onMetaChange()) }) + // Reflect an EXTERNAL `path` change back into `meta` (which drives the + // owner/name inputs). The effect above is one-way meta→path; without + // this counterpart, a parent that reassigns `path` — e.g. "Discard" + // reverting a draft to the deployed value — leaves the inputs showing + // the stale value until a remount. Guard against a meta↔path loop: skip + // when `path` already matches what `meta` produces (the meta→path write), + // and only adopt a derivation that round-trips cleanly (so a malformed + // path left to `initPath`/`reset` can't oscillate). + $effect.pre(() => { + const p = path + untrack(() => { + if (p == undefined || p == '' || p.startsWith('tmp/') || p.startsWith('hub/')) return + if (!meta || metaToPath(meta) === p) return + const next = pathToMeta(p, hideUser) + if (metaToPath(next) === p) { + meta = next + } + }) + }) $effect.pre(() => { if ($workspaceStore && $userStore) { untrack(() => { diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 126ab0dc7f..d503763a06 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -10,11 +10,10 @@ import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' import Alert from './common/alert/Alert.svelte' import { resource } from 'runed' - import { deepEqual } from 'fast-equals' import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' - import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' - import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' + import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' + import { setLocalDraftHint } from '$lib/localDraftHints.svelte' interface Props { canSave?: boolean @@ -75,53 +74,16 @@ let existedInitially: Record = $state({}) let fetchedResources: Record = $state({}) let perWsUser: Record = $state({}) - // Backend `edited_at` per workspace — the rev the staleness check - // compares the local autosave's recorded rev against. Resources have - // no DB-draft concept, so only `remoteRev` is ever populated. - let fetchedRev: Record = $state({}) - - // Local-draft staleness modal: opened when the backend resource moved - // on (someone else edited it) since the local autosave was written. - let staleModalOpen = $state(false) - let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined - - function onStaleLoadLatest(): void { - if (!pendingStale) { - staleModalOpen = false - return - } - const { ws, backend } = pendingStale - // Drop the divergent autosave and reset the handle to the freshly - // fetched backend state. A later edit re-creates the autosave and - // the seeding effect records the new rev. - UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws }) - initialStates[ws] = $state.snapshot(backend) as ResourceState - pendingStale = undefined - staleModalOpen = false - } - - function onStaleKeepDraft(): void { - if (pendingStale) { - const { ws } = pendingStale - // Ack the new backend rev so the modal doesn't fire again until - // the backend moves once more. Keeps the local autosave intact. - UserDraft.saveMeta( - 'resource', - initialPath ?? '', - { remoteRev: fetchedRev[ws] }, - { workspace: ws } - ) - } - pendingStale = undefined - staleModalOpen = false - } const handlesArray = UserDraft.useMany(() => workspaceSpecs.map((s) => ({ itemKind: 'resource' as const, path: initialPath ?? '', workspace: s.ws, - defaultValue: s.defaultValue + defaultValue: s.defaultValue, + // Autosaves landing back on the deployed value become deletes; + // `existedInitially` guards draft-only items from self-destructing. + discardIf: (val) => !!existedInitially[s.ws] && draftValuesEqual(val, initialStates[s.ws]) })) ) const states = $derived.by(() => { @@ -195,9 +157,24 @@ ) const dirtyWorkspaces = $derived( - Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) + Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + + // The syncer owns the list-page `*` hint; the editor only CLEARS it when a + // workspace is at the deployed baseline (so a draft discarded elsewhere + // vanishes on reopen). Never SET here. See VariableEditor for the full note. + $effect(() => { + const p = initialPath + const loadedWs = Object.keys(states) + const dirty = dirtyWorkspaces + untrack(() => { + if (!p) return + for (const ws of loadedWs) { + if (!dirty.includes(ws)) setLocalDraftHint(ws, 'resource', p, false) + } + }) + }) // Banner is scoped to the selected workspace — the diff/discard only // operate on it, so showing it for an unrelated dirty workspace would be // misleading. The cross-workspace `otherDirty` alert below still covers @@ -250,49 +227,28 @@ if (ws in states) return untrack(() => { Promise.all([ - ResourceService.getResource({ workspace: ws, path: initialPath }), + ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }), getUserExt(ws) ]).then(([r, user]) => { + // `.draft` already holds the editor's `ResourceState` shape. + const savedDraftState = (r as any).draft as ResourceState | undefined fetchedResources[ws] = r - fetchedRev[ws] = r.edited_at - const s: ResourceState = { + // Deployed baseline as the dirty-check reference, so the banner + // compares draft-vs-deployed and fires immediately when a draft exists. + const deployedState: ResourceState = { path: r.path, description: r.description ?? '', args: (r.value ?? {}) as any, labels: r.labels ?? undefined, wsSpecific: r.ws_specific ?? false } - // Reconcile the local autosave with the backend before the - // handle is registered. If the backend moved on since the - // autosave was written (recorded rev != current rev) surface - // the staleness modal; otherwise the form is just showing the - // user's unsaved work — a toast with a "Reset to deployed" - // escape is enough. - const persisted = UserDraft.get('resource', initialPath ?? '', { - workspace: ws - }) - const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws }) - if (persisted !== undefined && !deepEqual(persisted, s)) { - const cause = checkStaleness(previousMeta, r.edited_at) - if (cause) { - pendingStale = { ws, backend: s } - staleModalOpen = true - } else { - if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { - // Legacy autosave (no rev recorded) — backfill so the - // next backend change is detectable as drift. - UserDraft.saveMeta( - 'resource', - initialPath ?? '', - { remoteRev: r.edited_at }, - { workspace: ws } - ) - } - } - } + // Open with the saved draft if present, else the deployed. + const s: ResourceState = savedDraftState ?? deployedState ensureHandle(ws, s) - initialStates[ws] = structuredClone(s) - existedInitially[ws] = true + initialStates[ws] = structuredClone(deployedState) + // Draft-only paths (`no_deployed`) have no row — saving must + // CREATE, not update (update 404s). + existedInitially[ws] = !(r as any).no_deployed perWsUser[ws] = user // Keep resource_type in sync for the base workspace (controls the schema) if (ws === effectiveWorkspace) { @@ -302,25 +258,6 @@ }) }) - // Seed the staleness rev the moment a real autosave appears. Until the - // user's first edit diverges the handle's draft from the backend - // baseline there's no autosave to attach a rev to; once it does, record - // the backend rev captured at fetch time so a later external edit is - // detectable as drift on the next open. Self-limiting: after the write - // `meta.remoteRev` is set so the guard fails on the re-run. - $effect(() => { - for (const ws of Object.keys(states)) { - const h = states[ws] - const rev = fetchedRev[ws] - const baseline = initialStates[ws] - if (!h || rev === undefined || baseline === undefined) continue - const draft = h.draft - if (draft === undefined || deepEqual(draft, baseline)) continue - if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue - untrack(() => h.setMeta({ remoteRev: rev })) - } - }) - // Keep current.path bound to the outer `path` prop for consumers $effect(() => { if (current) path = current.path @@ -417,13 +354,11 @@ } }) } - // Saved on the backend — drop the local autosave for this - // workspace and refresh the dirty baseline. `s` is the - // UserDraft handle's draft, a Svelte $state proxy; - // `structuredClone` can't clone a proxy, so snapshot it to a - // plain object first. + // Reset the handle to the new deployed baseline via `discard`, not + // `remove`. See VariableEditor for the full rationale. initialStates[ws] = $state.snapshot(s) as ResourceState - UserDraft.remove('resource', initialPath ?? '', { workspace: ws }) + existedInitially[ws] = true + UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -438,13 +373,6 @@ } - -
{#if otherDirty.length > 0} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index f5f59cec34..f4d7e3e17d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -3,10 +3,9 @@ const bubble = createBubbler() import { - DraftService, ScriptService, - type NewScriptWithDraft, type Script, + type NewScript, type TriggersCount, PostgresTriggerService, CaptureService, @@ -31,7 +30,6 @@ workspaceStore } from '$lib/stores' import { - cleanValueProperties, emptySchema, emptyString, generateRandomString, @@ -59,14 +57,13 @@ EllipsisVertical, Plus, Rocket, - Save, Settings, Shuffle, Tag, X } from 'lucide-svelte' import DropdownV2 from './DropdownV2.svelte' - import { isMac, type Item } from '$lib/utils' + import { type Item } from '$lib/utils' import { sendUserToast } from '$lib/toast' import { isCloudHosted } from '$lib/cloud' import Awareness from './Awareness.svelte' @@ -82,8 +79,9 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { getContext, onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, tick, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' + import AutosaveIndicator from './AutosaveIndicator.svelte' import LabelsInput from './LabelsInput.svelte' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' @@ -92,13 +90,7 @@ import CaptureTable from './triggers/CaptureTable.svelte' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' - import { - type NewScriptWithDraftAndDraftTriggers, - type Trigger, - deployTriggers, - filterDraftTriggers, - handleSelectTriggerFromKind - } from './triggers/utils' + import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils' import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte' import { Triggers } from './triggers/triggers.svelte' import type { ScriptBuilderProps } from './script_builder' @@ -109,11 +101,14 @@ import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' + import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' let { script = $bindable(), fullyLoaded = true, initialPath = $bindable(''), + userDraftPath = '', template = $bindable('script'), initialArgs = {}, lockedLanguage = false, @@ -129,15 +124,16 @@ children, onDeploy, onDeployError, - onSaveInitial, onSeeDetails, - onSaveDraftError, - onSaveDraft, onNavigate, onTestJob, disableAi, initialTestPanelCollapsed = false, - initialPathChosen = false + initialPathChosen = false, + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -166,18 +162,10 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) - const mod = isMac() ? '⌘' : 'Ctrl+' function getCompactMenuItems(): Item[] { const hasTags = ($workerTags?.length ?? 0) > 0 return [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: initialPath != '' && !savedScript - }, ...(customUi?.topBar?.tagEdit != false && hasTags ? [ { @@ -292,13 +280,6 @@ $primaryScheduleStore, $userStore ) - - if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) { - savedScript = filterDraftTriggers( - savedScript, - triggersState - ) as NewScriptWithDraftAndDraftTriggers - } } // Add triggers context store @@ -369,9 +350,12 @@ let pathError = $state('') let loadingSave = $state(false) - let loadingDraft = $state(false) if (script.content == '') { + // Suspend autosave around the bootstrap mutations: seeding the template + // content is a programmatic write, not the user's first edit. The handle + // keys on `userDraftPath` (URL path), not the editor-displayed `initialPath`. + UserDraft.stopSync('script', userDraftPath) if (template === 'wac_python') { script.modules = { 'helper.py': { @@ -387,7 +371,36 @@ } } } - initContent(script.language, script.kind, template) + // Sync resumes only after two cascades settle: the async `initContent`, + // and the stores-gated `initPath → reset → onMetaChange → bind:path` + // auto-naming chain. Whichever lands last calls `tryRestart`; otherwise + // the auto-generated path posts as the first "user edit". + let initContentDone = false + let storesReady = !!($userStore && $workspaceStore) + let restarted = false + async function tryRestart() { + if (restarted || !initContentDone || !storesReady) return + // 500ms covers the bind:path cascade even on cold reload; two ticks + // weren't enough (bind:path fired ~100ms after restart, posting an edit). + await new Promise((r) => setTimeout(r, 500)) + if (restarted) return + restarted = true + UserDraft.restartSync('script', userDraftPath) + } + initContent(script.language, script.kind, template).finally(() => { + initContentDone = true + void tryRestart() + }) + // Cold reload: auth stores may load after mount; the `restarted` guard + // makes the effect self-cleaning. + if (!storesReady) { + $effect(() => { + if ($userStore && $workspaceStore) { + storesReady = true + untrack(() => void tryRestart()) + } + }) + } } async function isTemplateScript() { @@ -427,8 +440,13 @@ | 'ci_test_python' ) { scriptEditor?.disableCollaboration() + // Seed synchronously so a Deploy before the async template fetch resolves + // doesn't run `inferArgs` on empty content and toast "Could not parse code". + script.content = initialCode(language, kind, template, false) const templateScript = await isTemplateScript() - script.content = initialCode(language, kind, template, templateScript != undefined) + if (templateScript) { + script.content = initialCode(language, kind, template, true) + } if (templateScript) { script.content += '\r\n' + templateScript } @@ -533,7 +551,12 @@ loadingSave = true try { - script.schema = script.schema ?? emptySchema() + // Legacy drafts can carry `schema: {}` (no `properties`), which trips + // `inferArgs` on `JSON.stringify(schema.properties)` and toasts "Could + // not parse code". Backfill an empty schema so it parses. + if (!script.schema || !(script.schema as any).properties) { + script.schema = emptySchema() + } try { const result = await inferArgs( script.language, @@ -623,7 +646,7 @@ } const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script)) - savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft + savedScript = structuredClone($state.snapshot(newScript)) setDraftTriggers([]) if (!disableHistoryChange) { @@ -653,153 +676,19 @@ loadingSave = false } - async function saveDraft(forceSave = false): Promise { - scriptEditor?.flushModuleState() - if (initialPath != '' && !savedScript) { - return - } - - if (savedScript) { - const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript) - const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot()) - const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers }) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraft(true) - } - } - ]) - return - } - } - - loadingDraft = true - try { - script.schema = script.schema ?? emptySchema() - try { - const result = await inferArgs( - script.language, - script.content, - script.schema as any, - script.kind === 'preprocessor' ? 'preprocessor' : undefined - ) - if (script.kind === 'preprocessor') { - script.auto_kind = undefined - script.has_preprocessor = undefined - } else { - script.auto_kind = result?.auto_kind || undefined - script.has_preprocessor = result?.has_preprocessor || undefined - } - } catch (error) { - sendUserToast(`Could not parse code, are you sure it is valid?`, true) - } - let newHash = '' - if (initialPath == '' || savedScript?.draft_only) { - if (savedScript?.draft_only) { - await ScriptService.deleteScriptByPath({ - workspace: $workspaceStore!, - path: initialPath, - keepCaptures: true - }) - script.parent_hash = undefined - } - if (!initialPath || script.path != initialPath) { - await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, - path: initialPath || fakeInitialPath, - requestBody: { - new_path: script.path - }, - runnableKind: 'script' - }) - } - newHash = await ScriptService.createScript({ - workspace: $workspaceStore!, - requestBody: { - path: script.path, - summary: script.summary, - description: script.description ?? '', - content: script.content, - schema: script.schema, - is_template: script.is_template, - language: script.language, - kind: script.kind, - tag: script.tag, - draft_only: true, - envs: script.envs, - concurrent_limit: script.concurrent_limit, - concurrency_time_window_s: script.concurrency_time_window_s, - debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key, - debounce_delay_s: script.debounce_delay_s, - debounce_args_to_accumulate: - script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0 - ? script.debounce_args_to_accumulate - : undefined, - max_total_debouncing_time: script.max_total_debouncing_time, - max_total_debounces_amount: script.max_total_debounces_amount, - cache_ttl: script.cache_ttl, - cache_ignore_s3_path: script.cache_ignore_s3_path, - ws_error_handler_muted: script.ws_error_handler_muted, - priority: script.priority, - restart_unless_cancelled: script.restart_unless_cancelled, - timeout: script.timeout, - concurrency_key: emptyString(script.concurrency_key) - ? undefined - : script.concurrency_key, - visible_to_runner_only: script.visible_to_runner_only, - auto_kind: script.auto_kind, - has_preprocessor: script.has_preprocessor, - on_behalf_of_email: script.on_behalf_of_email, - assets: script.assets, - modules: script.modules, - labels: script.labels - } - }) - } - const draftTriggers = triggersState.getDraftTriggersSnapshot() - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath, - typ: 'script', - value: { - ...script, - draft_triggers: draftTriggers - } - } - }) - - const clonedScript = structuredClone($state.snapshot(script)) - savedScript = { - ...(initialPath == '' || savedScript?.draft_only - ? { ...clonedScript, draft_only: true } - : savedScript), - draft: { - ...clonedScript, - draft_triggers: draftTriggers - } - } as NewScriptWithDraftAndDraftTriggers - - let savedAtNewPath = false - if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) { - savedAtNewPath = true - initialPath = script.path - onSaveInitial?.({ path: script.path, hash: newHash }) - } - onSaveDraft?.({ path: script.path, savedAtNewPath, script }) - - sendUserToast('Saved as draft') - } catch (error) { - sendUserToast( - `Error while saving the script as a draft: ${error.body || error.message}`, - true - ) - onSaveDraftError?.({ path: script.path, error }) - } - loadingDraft = false + // Ctrl/Cmd+S forces an immediate flush of the pending autosave. Flush Monaco + // + `tick()` first so the last keystrokes reach the bindable before the + // syncer flushes. No toast — the AutosaveIndicator narrates the result, and + // `flush` never rejects (postSave routes errors to the failures map). + async function saveDraft(): Promise { + if (!$workspaceStore || !userDraftPath) return + editor?.flushPendingChanges() + await tick() + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'script', + path: userDraftPath + }) } // Inside an AI session pane (which injects an aiChatManager via context) the @@ -830,10 +719,7 @@ }) } - function computeDropdownItems( - initialPath: string, - savedScript: NewScriptWithDraftAndDraftTriggers | undefined - ) { + function computeDropdownItems(initialPath: string, savedScript: Script | NewScript | undefined) { let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ @@ -864,7 +750,7 @@ ] : []), ...(!inSessionPane && - !script.draft_only && + (savedScript as any)?.no_deployed !== true && script.kind === 'script' && !script.auto_kind ? [ @@ -994,17 +880,7 @@ } } - function handleDeployTrigger(trigger: Trigger) { - const { id, path, type } = trigger - //Update the saved script to remove the draft trigger that is deployed - if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) { - const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter( - (t) => t.id !== id || t.path !== path || t.type !== type - ) - savedScript.draft.draft_triggers = - newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined - } - } + function handleDeployTrigger(_trigger: Trigger) {} function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) { if (lang == 'docker') { @@ -1946,7 +1822,7 @@ {hasPreprocessor} canHavePreprocessor={canHavePreprocessor(script.language)} args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args} - isDeployed={savedScript && !savedScript?.draft_only} + isDeployed={savedScript && (savedScript as any)?.no_deployed !== true} schema={script.schema} runnableVersion={script.parent_hash} onDeployTrigger={handleDeployTrigger} @@ -1985,6 +1861,18 @@ onNavigate={(item) => onNavigate?.(item)} /> {/if} + {#if $workspaceStore} + + {/if}
@@ -2015,13 +1903,16 @@ {/snippet} {#snippet diffButton()} {#if customUi?.topBar?.diff != false} + {@const isDraftOnly = (savedScript as any)?.no_deployed === true} {/if} = $state({}) let selected: string | undefined = $state(undefined) let pathError = $state('') - // Backend `edited_at` per workspace — the rev the staleness check - // compares the local autosave's recorded rev against. Variables have - // no DB-draft concept, so only `remoteRev` is ever populated. - let fetchedRev: Record = $state({}) - - // Local-draft staleness modal: opened when the backend variable moved - // on (someone else edited it) since the local autosave was written. - let staleModalOpen = $state(false) - let pendingStale: { ws: string; backend: VariableState } | undefined = undefined - - function onStaleLoadLatest(): void { - if (!pendingStale) { - staleModalOpen = false - return - } - const { ws, backend } = pendingStale - UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws }) - initialStates[ws] = $state.snapshot(backend) as VariableState - pendingStale = undefined - staleModalOpen = false - } - - function onStaleKeepDraft(): void { - if (pendingStale) { - const { ws } = pendingStale - UserDraft.saveMeta( - 'variable', - editPath ?? '', - { remoteRev: fetchedRev[ws] }, - { workspace: ws } - ) - } - pendingStale = undefined - staleModalOpen = false - } const handlesArray = UserDraft.useMany(() => workspaceSpecs.map((s) => ({ itemKind: 'variable' as const, path: editPath ?? '', workspace: s.ws, - defaultValue: s.defaultValue + defaultValue: s.defaultValue, + // Autosaves landing back on the deployed value become deletes (same + // comparison as the banner's `dirtyWorkspaces`, so they can't disagree). + // Guarded by `existedInitially` so draft-only items aren't destroyed. + discardIf: (val) => !!existedInitially[s.ws] && draftValuesEqual(val, initialStates[s.ws]) })) ) const states = $derived.by(() => { @@ -125,8 +94,24 @@ return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore) }) const dirtyWorkspaces = $derived( - Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) + Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) + + // The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared + // on delete). The editor only CLEARS it — a workspace at the deployed + // baseline has no draft, so drop any stale hint (this is how a draft + // discarded in another tab vanishes on reopen). Never SET here. + $effect(() => { + const p = editPath + const loadedWs = Object.keys(states) + const dirty = dirtyWorkspaces + untrack(() => { + if (!p) return + for (const ws of loadedWs) { + if (!dirty.includes(ws)) setLocalDraftHint(ws, 'variable', p, false) + } + }) + }) const anyDirty = $derived(dirtyWorkspaces.length > 0) // Banner is scoped to the selected workspace — the diff/discard only // operate on it, so showing it for an unrelated dirty workspace would be @@ -141,7 +126,12 @@ const dirtyValid = $derived( dirtyWorkspaces.every((ws) => { const v = states[ws].draft - return !!v && v.variable.value.length <= MAX_VARIABLE_LENGTH + // `$encrypted:` markers are ciphertext; the backend re-derives the + // real value on save, so the length cap doesn't apply. + return ( + !!v && + (isEncryptedDraftValue(v.variable.value) || v.variable.value.length <= MAX_VARIABLE_LENGTH) + ) }) ) const dirtyCanWrite = $derived( @@ -159,11 +149,19 @@ if (ws in states) return untrack(() => { Promise.all([ - VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }), + VariableService.getVariable({ + workspace: ws, + path: p, + decryptSecret: false, + getDraft: true + }), getUserExt(ws) ]).then(([v, user]) => { - fetchedRev[ws] = v.edited_at - const s: VariableState = { + // `.draft` already holds the editor's `VariableState` shape. + const savedDraftState = (v as any).draft as VariableState | undefined + // Deployed baseline as the dirty-check reference, so the banner + // compares draft-vs-deployed and fires immediately when a draft exists. + const deployedState: VariableState = { path: v.path, variable: { value: v.value ?? '', @@ -173,48 +171,19 @@ labels: v.labels ?? undefined, wsSpecific: v.ws_specific ?? false } - // See ResourceEditor for the same pattern: a backend that - // moved on since the autosave was written → staleness modal; - // otherwise just a "showing your local autosave" toast with - // a "Reset to deployed" escape. - const persisted = UserDraft.get('variable', p, { workspace: ws }) - const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws }) - if (persisted !== undefined && !deepEqual(persisted, s)) { - const cause = checkStaleness(previousMeta, v.edited_at) - if (cause) { - pendingStale = { ws, backend: s } - staleModalOpen = true - } else { - if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { - UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws }) - } - } - } + // Open with the saved draft if present, else the deployed. + const s: VariableState = savedDraftState ?? deployedState ensureHandle(ws, s) - initialStates[ws] = structuredClone(s) - existedInitially[ws] = true + initialStates[ws] = structuredClone(deployedState) + // Draft-only paths (`no_deployed`) have no row — saving must + // CREATE, not update (update 404s). + existedInitially[ws] = !(v as any).no_deployed extraPerms[ws] = v.extra_perms ?? {} perWsUser[ws] = user }) }) }) - // Seed the staleness rev once a real autosave appears (see - // ResourceEditor for the rationale). Self-limiting via the - // meta-already-set guard. - $effect(() => { - for (const ws of Object.keys(states)) { - const h = states[ws] - const rev = fetchedRev[ws] - const baseline = initialStates[ws] - if (!h || rev === undefined || baseline === undefined) continue - const draft = h.draft - if (draft === undefined || deepEqual(draft, baseline)) continue - if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue - untrack(() => h.setMeta({ remoteRev: rev })) - } - }) - function reset() { // Clearing workspaceSpecs triggers useMany's reconcile to release // every acquired entry. The $derived `states` then collapses to {}. @@ -300,8 +269,13 @@ } }) } - // Saved on the backend — drop the local autosave for this workspace. - UserDraft.remove('variable', editPath ?? '', { workspace: ws }) + // The just-saved state is the new deployed baseline; reset the + // handle to it via `discard` (not `remove` — blanking the cell to + // `undefined` reads as dirty). The `value: null` POST also deletes + // the server draft row so `is_draft` clears on refetch. + initialStates[ws] = $state.snapshot(s) as VariableState + existedInitially[ws] = true + UserDraft.discard('variable', editPath ?? '', s, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -315,13 +289,6 @@ } - -
{#if $mode !== 'preview'} @@ -1210,19 +1073,6 @@
- {#if !compactTopbar} - - {/if}
- {#if !savedApp} - + {#if !savedApp || newApp} + {:else if secretUrlHref}
- import { Alert } from '$lib/components/common' - import Path from '$lib/components/Path.svelte' - - let { - summary = $bindable(), - appPath = $bindable(), - pathError = $bindable(), - newEditedPath = $bindable() - } = $props() - - let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) - - - - Choose a path to save the initial draft of the app. - -

Summary

-
- - { - e.stopPropagation() - }} - bind:value={summary} - onkeyup={() => { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - }} - /> -
-
- -
diff --git a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte index 3431e906ad..c268058717 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte @@ -26,7 +26,7 @@ targetTutorial = undefined }} on:confirmed={async () => { - window.open(`/apps/add?tutorial=${targetTutorial}&nodraft=true`, '_blank') + window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank') }} >
diff --git a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte index e7a35ded20..1e44b147f3 100644 --- a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte @@ -1,11 +1,12 @@ jsonViewerDrawer?.toggleDrawer()}> - {#if useDraft} -
- +Draft -
- {/if} {#if loading} {:else} @@ -80,11 +88,13 @@ {#snippet actions()} {#if !$userStore?.operator} - - {/if} {/snippet} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 64b08d621e..8eef469472 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -149,11 +149,9 @@ export interface AppEditorProps { savedApp?: | { value: App - draft?: any path: string summary: string policy: any - draft_only?: boolean custom_path?: string } | undefined @@ -162,20 +160,18 @@ export interface AppEditorProps { newPath?: string | undefined replaceStateFn?: (path: string) => void gotoFn?: (path: string, opt?: Record | undefined) => void - unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void - /** - * Backend revs at the load that produced `app`. Used as the seed - * `UserDraft` meta on the first local autosave: until the handle has - * its own meta (set on a previous reload, or by route backfill), the - * mirror `$effect` injects these revs so the next reload's staleness - * check has something to compare the current backend rev against. - * Without this, the first deploy-after-edit can't be detected as - * drift — `previousMeta` would be empty and the modal wouldn't fire. - */ - initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta + // Threaded through `AppEditorHeader` to the `AutosaveIndicator` + // popover so its "Reset to deployed" button can do the same thing + // the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the app editor's + // indicator. Threaded through AppEditorHeader. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void } export type App = { @@ -198,6 +194,15 @@ export type App = { hideLegacyTopBar?: boolean | undefined mobileViewOnSmallerScreens?: boolean | undefined version?: number + /** + * User-typed path persisted on the autosaved App when it differs from + * the deployed/seeded baseline. The home list renders it so a friendly + * name shows up instead of the autogenerated `u/{user}/draft_{uuid}` + * URL slot for renames-in-progress and brand-new drafts. Dropped from + * the JSON once the typed path matches the baseline again, and deploy + * clears the whole draft. + */ + draft_path?: string } export type ConnectingInput = { diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte new file mode 100644 index 0000000000..8bd9eeebd1 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -0,0 +1,102 @@ + + +{#if enabled && workspace && path} + + {#if otherDraftsUsers.length > 0} + {#key path} + + {/key} + {/if} + {#if onLoadLatestDeploy} + + {/if} +{/if} diff --git a/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte new file mode 100644 index 0000000000..ca467fd571 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte @@ -0,0 +1,92 @@ + + + +
+
+ +
+

+ Another tab, browser, or AI agent saved a newer version of this draft. Your autosave was + rejected to avoid overwriting their work. +

+ {#if conflictHandle.conflict} +

+ Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()} +

+ {/if} +
+
+ +
+ + + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte b/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte deleted file mode 100644 index 94782f176e..0000000000 --- a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - - -{#if open} - -{/if} diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte new file mode 100644 index 0000000000..493931e640 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -0,0 +1,169 @@ + + + +
+
+ +

+ Their drafts are independent of yours. For advanced collaboration, consider using workspace forks (EE) +

+
+ +
    + {#each otherDraftsUsers as owner (ownerKey(owner))} +
  • +
    + + {ownerLabel(owner)} + + {#if !owner.username} + + Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user + — kept around so you can recover the content, but no current user owns it. + + {/if} +
    + + +
  • + {/each} +
+ +
+ +
+
+
+ + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte b/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte new file mode 100644 index 0000000000..cc027c6f12 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte @@ -0,0 +1,86 @@ + + + +
+
+ +
+

+ A newer version was deployed after you started editing. Your draft is based on the older + deploy. +

+

+ Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} +

+
+
+ +
+ + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 55a78245be..b39a718572 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -2,8 +2,6 @@ import ConfirmationModal from './ConfirmationModal.svelte' import { beforeNavigate } from '$app/navigation' import { goto as gotoUrl } from '$app/navigation' - import Button from '../button/Button.svelte' - import type DiffDrawer from '$lib/components/DiffDrawer.svelte' import { cleanValueProperties, orderedJsonStringify, @@ -16,7 +14,6 @@ interface Props { getInitialAndModifiedValues?: GetInitialAndModifiedValues - diffDrawer?: DiffDrawer | undefined additionalExitAction?: () => void triggerOnSearchParamsChange?: boolean onDiscardChanges?: () => void @@ -25,7 +22,6 @@ let { getInitialAndModifiedValues = undefined, - diffDrawer = undefined, additionalExitAction = () => {}, triggerOnSearchParamsChange = false, onDiscardChanges = undefined, @@ -125,37 +121,5 @@ >
Are you sure you want to discard the changes you have made? - {#if savedValue && modifiedValue && diffDrawer} - - {/if}
diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index 36fbb23c5f..fcce008ad7 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -15,8 +15,15 @@ target?: string isOpen?: boolean fixedWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' - fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + /** `adaptive` sizes the modal to its content (no fixed height, + * still capped by max-h-screen-80). */ + fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'adaptive' contentClasses?: string + /** Close when the user clicks outside the modal body. Default + * true. Set false when the caller stacks a child modal on top + * and clicks "outside" the child would otherwise propagate + * here and close the underlying modal. */ + closeOnOutsideClick?: boolean headerLeft?: import('svelte').Snippet headerRight?: import('svelte').Snippet children?: import('svelte').Snippet @@ -25,11 +32,15 @@ let { title, css = {}, - target = '', + // Forwarded to `Portal`. An empty string would hit + // `document.querySelector('')` and throw "The provided selector + // is empty" — match `Portal`'s own default instead. + target = 'body', isOpen = $bindable(false), fixedWidth = 'md', fixedHeight = 'md', contentClasses = '', + closeOnOutsideClick = true, headerLeft, headerRight, children @@ -49,7 +60,9 @@ md: '500px', lg: '720px', xl: '800px', - xxl: '1000px' + xxl: '1000px', + // Content-driven height — emit no `height:` rule at all. + adaptive: undefined } export function close() { @@ -61,6 +74,7 @@ } function handleKeyDown(event: KeyboardEvent) { + if (!isOpen) return if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() @@ -83,15 +97,17 @@ >
close() }} + use:clickOutside={{ + onClickOutside: () => closeOnOutsideClick && close() + }} >
diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index bc84f0f884..5682bd47e0 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -3,13 +3,14 @@ import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { AppService, DraftService, type ListableApp } from '$lib/gen' + import { AppService, type ListableApp } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import InheritedLabels from '$lib/components/InheritedLabels.svelte' import Badge from '../badge/Badge.svelte' import { @@ -29,7 +30,7 @@ import { goto as gotoUrl } from '$app/navigation' import { page } from '$app/state' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import { DELETE, copyToClipboard } from '$lib/utils' + import { copyToClipboard } from '$lib/utils' import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte' import { isDeployable } from '$lib/utils_deployable' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' @@ -38,7 +39,7 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } + app: ListableApp & { draft_only?: boolean; canWrite: boolean } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -65,11 +66,31 @@ const dispatch = createEventDispatcher() - let appExport: { open: (path: string) => void } | undefined = $state(undefined) + let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined) let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined) async function loadAppJson() { - appExport?.open(app.path) + // Thread the row's `raw_app` flag so the JSON drawer's backend + // fetch picks the right draft kind on draft-only items (no + // deployed row to read the kind from server-side). + appExport?.open(app.path, !!app.raw_app) + } + + async function deleteApp(path: string): Promise { + // Draft-only items have no deployed row — the regular route would + // 404. Route the delete through the syncer instead; the `app` vs + // `raw_app` choice mirrors the row's own `raw_app` flag. + if (app.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore ?? '', + itemKind: app.raw_app ? 'raw_app' : 'app', + path, + value: null, + immediate: true + }) + } else { + await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + } } @@ -84,8 +105,8 @@ href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}" kind="app" {marked} - path={app.path} - summary={app.summary} + path={(app as any).draft_path ?? app.path} + summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary} workspaceId={app.workspace_id ?? $workspaceStore ?? ''} canFavorite={!app.draft_only} {depth} @@ -99,7 +120,15 @@ Raw {/if} - + {#if app.labels?.length}
{#each app.labels.slice(0, 3) as label} @@ -132,7 +161,7 @@ variant="subtle" wrapperClasses="w-20" startIcon={{ icon: Pen }} - href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}?nodraft=true" + href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}" > Edit @@ -157,7 +186,7 @@ aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`} aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`} items={async () => { - let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app + let { draft_only, canWrite, summary, execution_mode, path } = app const canEdit = canWrite && showEditButton if (draft_only) { @@ -169,11 +198,11 @@ // TODO // @ts-ignore if (event?.shiftKey) { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } else { deleteConfirmedCallback = async () => { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } } @@ -273,25 +302,6 @@ } ] : []), - ...(has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path, - kind: 'app' - }) - dispatch('change') - }, - type: DELETE, - disabled: !canWrite, - hide: $userStore?.operator - } - ] - : []), { displayName: 'Delete', icon: Trash, @@ -299,11 +309,11 @@ // TODO // @ts-ignore if (event?.shiftKey) { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } else { deleteConfirmedCallback = async () => { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } } diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index aaed01e6d7..3dca4ac60d 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -5,16 +5,17 @@ import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { FlowService, type Flow, DraftService } from '$lib/gen' + import { FlowService, type Flow } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { DELETE, copyToClipboard, isOwner } from '$lib/utils' + import { copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -40,7 +41,13 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } + flow: Flow & { + draft_only?: boolean + is_draft?: boolean + draft_path?: string + draft_users?: { username?: string | null }[] + canWrite: boolean + } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -85,7 +92,20 @@ async function deleteFlow(path: string): Promise { try { - await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) + // Draft-only items have no deployed row to delete — the regular + // route would 404. Route the delete through the syncer so the + // per-user draft row is removed instead. + if (flow.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore!, + itemKind: 'flow', + path, + value: null, + immediate: true + }) + } else { + await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) + } dispatch('change') sendUserToast(`Deleted flow ${path}`) } catch (err) { @@ -105,13 +125,13 @@ aiId={`flow-row-${flow.path}`} aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`} href={flow.draft_only - ? `${base}/flows/edit/${flow.path}?nodraft=true` + ? `${base}/flows/edit/${flow.path}` : `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`} kind="flow" workspaceId={flow.workspace_id ?? $workspaceStore ?? ''} {marked} - path={flow.path} - summary={flow.summary} + path={flow.draft_path ?? flow.path} + summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary} {errorHandlerMuted} canFavorite={!flow.draft_only} {depth} @@ -122,7 +142,15 @@ archived {/if} - + {#if flow.labels?.length}
{#each flow.labels.slice(0, 3) as label} @@ -154,7 +182,7 @@ wrapperClasses="w-20" unifiedSize="md" startIcon={{ icon: Pen }} - href="{base}/flows/edit/{flow.path}?nodraft=true" + href="{base}/flows/edit/{flow.path}" aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`} > @@ -182,7 +210,7 @@ aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`} items={async () => { - let { draft_only, path, archived, has_draft } = flow + let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton if (draft_only) { @@ -295,25 +323,6 @@ disabled: !owner || !canEdit, hide: $userStore?.operator }, - ...(has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path, - kind: 'flow' - }) - dispatch('change') - }, - type: DELETE, - disabled: !owner, - hide: $userStore?.operator - } - ] - : []), { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 2174776167..be7f63dace 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -5,18 +5,19 @@ import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { ScriptService, type Script, DraftService } from '$lib/gen' + import { ScriptService, type Script } from '$lib/gen' import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils' + import { capitalize, copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -52,7 +53,13 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - script: Script & { canWrite: boolean; use_codebase: boolean } + script: Script & { + canWrite: boolean + use_codebase: boolean + is_draft?: boolean + draft_path?: string + draft_users?: { username?: string | null }[] + } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -104,7 +111,20 @@ } async function deleteScript(path: string): Promise { - await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) + // Draft-only items have no deployed row to delete — the regular + // route would 404. Route the delete through the syncer so the + // per-user draft row is removed instead. + if (script.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore!, + itemKind: 'script', + path, + value: null, + immediate: true + }) + } else { + await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) + } dispatch('change') sendUserToast(`Deleted script ${path}`) } @@ -127,8 +147,10 @@ : `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`} kind="script" {marked} - path={script.path} - summary={script.summary} + path={script.draft_path ?? script.path} + summary={script.is_draft + ? `${script.summary || script.draft_path || script.path}*` + : script.summary} {errorHandlerMuted} workspaceId={$workspaceStore ?? ''} canFavorite={!script.draft_only} @@ -169,7 +191,15 @@ > {/if} - + {#if script.labels?.length}
{#each script.labels.slice(0, 3) as label} @@ -406,25 +436,6 @@ hide: $userStore?.operator }, - ...(script.has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path: script.path, - kind: 'script' - }) - dispatch('change') - }, - type: DELETE, - disabled: !owner, - hide: $userStore?.operator - } - ] - : []), ...($userStore?.is_admin || $userStore?.is_super_admin ? [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index d6eb6803e4..536abff952 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -48,12 +48,6 @@ vi.mock('$lib/gen', async () => { getScriptByPath: vi.fn(async () => { throw new Error('getScriptByPath mock not configured') }), - getScriptByHash: vi.fn(async () => { - throw new Error('getScriptByHash mock not configured') - }), - getScriptByPathWithDraft: vi.fn(async () => { - throw new Error('getScriptByPathWithDraft mock not configured') - }), queryHubScripts: vi.fn(async () => []), getHubScriptContentByPath: vi.fn(async () => ''), listScripts: vi.fn(async () => []) @@ -109,9 +103,6 @@ vi.mock('$lib/gen', async () => { getFlowByPath: vi.fn(async () => { throw new Error('getFlowByPath mock not configured') }), - getFlowByPathWithDraft: vi.fn(async () => { - throw new Error('getFlowByPathWithDraft mock not configured') - }), getFlowLatestVersion: vi.fn(async () => ({ id: 1 })), listFlows: vi.fn(async () => []) }), @@ -131,8 +122,8 @@ vi.mock('$lib/gen', async () => { existsApp: vi.fn(async () => false), createAppRaw: vi.fn(async () => 'created'), updateAppRaw: vi.fn(async () => 'updated'), - getAppByPathWithDraft: vi.fn(async () => { - throw new Error('getAppByPathWithDraft mock not configured') + getAppByPath: vi.fn(async () => { + throw new Error('getAppByPath mock not configured') }), listApps: vi.fn(async () => []) }), @@ -423,9 +414,6 @@ describe('global AI tools', () => { wsSpecific: true, resource_type: 'postgresql' }) - expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ - remoteRev: '2026-05-22T09:30:00Z' - }) }) it('writes variable drafts in the editor UserDraft shape', async () => { @@ -463,9 +451,6 @@ describe('global AI tools', () => { is_oauth: true, expires_at: '2026-06-22T09:30:00Z' }) - expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ - remoteRev: '2026-05-22T09:30:00Z' - }) expect(localStorageSnapshot()).not.toContain('new-secret-token') }) @@ -751,23 +736,14 @@ describe('global AI tools', () => { it('preserves existing script metadata and seeds freshness on first script write', async () => { vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ path: 'f/scripts/existing', hash: 'deployed-hash', - draft_created_at: '2026-05-22T10:00:00Z', summary: 'deployed summary', description: 'deployed description', content: 'old deployed content', language: 'bun', - kind: 'script', - draft: { - path: 'f/scripts/existing', - summary: 'db draft summary', - description: 'db draft description', - content: 'old draft content', - language: 'bun', - kind: 'script' - } + kind: 'script' } as any) await callGlobalTool('write_script', { @@ -783,20 +759,16 @@ describe('global AI tools', () => { path: 'f/scripts/existing', parent_hash: 'deployed-hash', summary: 'new summary', - description: 'db draft description', + description: 'deployed description', content: 'new content', language: 'bun' }) - expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({ - remoteRev: 'deployed-hash', - remoteDraftRev: '2026-05-22T10:00:00Z' - }) }) it('preserves existing flow metadata and seeds freshness on first flow write', async () => { vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any) - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ path: 'f/flows/existing', summary: 'deployed summary', description: 'deployed description', @@ -805,19 +777,7 @@ describe('global AI tools', () => { edited_by: 'admin', edited_at: '2026-05-22T09:00:00Z', archived: false, - extra_perms: {}, - draft_created_at: '2026-05-22T10:00:00Z', - draft: { - path: 'f/flows/existing', - summary: 'db draft summary', - description: 'db draft description', - value: { modules: [] }, - schema: { properties: { draft: { type: 'string' } } }, - edited_by: 'admin', - edited_at: '2026-05-22T09:30:00Z', - archived: false, - extra_perms: {} - } + extra_perms: {} } as any) await callGlobalTool('write_flow', { @@ -829,13 +789,9 @@ describe('global AI tools', () => { expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({ path: 'f/flows/existing', summary: 'new summary', - description: 'db draft description', + description: 'deployed description', value: { modules: [{ id: 'step', value: { type: 'identity' } }] } }) - expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({ - remoteRev: 42, - remoteDraftRev: '2026-05-22T10:00:00Z' - }) }) it('preserves editor schedule fields when writing over an existing schedule', async () => { @@ -947,32 +903,22 @@ describe('global AI tools', () => { }) it('seeds raw app draft metadata on first app write', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [3, 4], - draft_created_at: '2026-05-22T10:30:00Z', value: { files: { '/src/App.tsx': 'deployed content' }, - runnables: {}, - data: { tables: [] } + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } }, policy: { execution_mode: 'publisher' }, - custom_path: 'report', - draft: { - summary: 'saved app draft', - value: { - files: { '/src/App.tsx': 'draft content' }, - runnables: { - main: { - type: 'inline', - inlineScript: { language: 'bun', content: 'export async function main() {}' } - } - }, - data: { tables: ['orders'], datatable: 'db', schema: 'public' } - }, - policy: { execution_mode: 'anonymous' } - } + custom_path: 'report' } as any) await callGlobalTool('write_app_file', { @@ -983,9 +929,9 @@ describe('global AI tools', () => { const draft = UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE }) expect(draft).toMatchObject({ - summary: 'saved app draft', + summary: 'deployed app', files: { - '/src/App.tsx': 'draft content', + '/src/App.tsx': 'deployed content', '/src/New.tsx': 'export default function New() { return null }' }, runnables: { @@ -995,13 +941,9 @@ describe('global AI tools', () => { } }, data: { tables: ['orders'], datatable: 'db', schema: 'public' }, - policy: { execution_mode: 'anonymous' }, + policy: { execution_mode: 'publisher' }, custom_path: 'report' }) - expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({ - remoteRev: 4, - remoteDraftRev: '2026-05-22T10:30:00Z' - }) }) it('summarizes local raw app drafts in read_workspace_item', async () => { @@ -1055,39 +997,31 @@ describe('global AI tools', () => { expect(item.value.backend[0]).not.toHaveProperty('content') }) - it('summarizes backend raw app drafts from the same source as file reads', async () => { - const appWithDraft = { + it('summarizes backend raw apps from the same source as file reads', async () => { + const deployedApp = { path: 'f/apps/report', summary: 'deployed app', versions: [5], value: { - files: { '/src/App.tsx': 'deployed content' }, - runnables: {}, - data: { tables: ['deployed'] } - }, - draft: { - summary: 'saved app draft', - value: { - files: { - '/src/App.tsx': 'draft content', - '/src/DraftOnly.tsx': 'draft-only content' - }, - runnables: { - main: { - type: 'inline', - inlineScript: { - language: 'bun', - content: 'export async function main() { return "draft" }' - } + files: { + '/src/App.tsx': 'deployed content', + '/src/Helper.tsx': 'helper content' + }, + runnables: { + main: { + type: 'inline', + inlineScript: { + language: 'bun', + content: 'export async function main() { return "deployed" }' } - }, - data: { tables: ['draft'] } - } + } + }, + data: { tables: ['deployed'] } } } - vi.mocked(AppService.getAppByPathWithDraft) - .mockResolvedValueOnce(appWithDraft as any) - .mockResolvedValueOnce(appWithDraft as any) + vi.mocked(AppService.getAppByPath) + .mockResolvedValueOnce(deployedApp as any) + .mockResolvedValueOnce(deployedApp as any) const raw = await callGlobalTool('read_workspace_item', { type: 'app', @@ -1095,15 +1029,14 @@ describe('global AI tools', () => { }) const item = JSON.parse(raw) - expect(raw).not.toContain('draft-only content') expect(item).toMatchObject({ type: 'app', path: 'f/apps/report', - summary: 'saved app draft', + summary: 'deployed app', value: { frontend: [ - { path: '/src/App.tsx', size: 'draft content'.length }, - { path: '/src/DraftOnly.tsx', size: 'draft-only content'.length } + { path: '/src/App.tsx', size: 'deployed content'.length }, + { path: '/src/Helper.tsx', size: 'helper content'.length } ], backend: [ expect.objectContaining({ @@ -1111,10 +1044,10 @@ describe('global AI tools', () => { name: 'main', type: 'inline', language: 'bun', - contentSize: 'export async function main() { return "draft" }'.length + contentSize: 'export async function main() { return "deployed" }'.length }) ], - data: { tables: ['draft'] } + data: { tables: ['deployed'] } }, isDraft: false }) @@ -1122,14 +1055,14 @@ describe('global AI tools', () => { await expect( callGlobalTool('read_app_file', { path: 'f/apps/report', - file_path: '/src/DraftOnly.tsx' + file_path: '/src/Helper.tsx' }) - ).resolves.toBe('draft-only content') + ).resolves.toBe('helper content') expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('reads raw app files without creating a local draft', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], @@ -1137,14 +1070,6 @@ describe('global AI tools', () => { files: { '/src/App.tsx': 'deployed content' }, runnables: {}, data: { tables: [] } - }, - draft: { - summary: 'saved app draft', - value: { - files: { '/src/App.tsx': 'draft content' }, - runnables: {}, - data: { tables: [] } - } } } as any) @@ -1153,12 +1078,12 @@ describe('global AI tools', () => { path: 'f/apps/report', file_path: '/src/App.tsx' }) - ).resolves.toBe('draft content') + ).resolves.toBe('deployed content') expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('does not persist a raw app draft when patch_app_file validation fails', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], @@ -1182,7 +1107,7 @@ describe('global AI tools', () => { }) it('does not persist a raw app draft when delete_app_file validation fails', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], @@ -1203,7 +1128,7 @@ describe('global AI tools', () => { }) it('does not persist a raw app draft when delete_app_runnable validation fails', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 7c3040e284..d3e1e6889d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -74,7 +74,7 @@ import { } from '../shared' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' -import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' +import { UserDraft } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' import { inferArgs } from '$lib/infer' import { @@ -129,10 +129,7 @@ const INSTRUCTION_SUBJECTS = [ // `datatable` is not a workspace item type, but the model can request the // datatable SDK reference (the wmill.datatable() runnable API) the same way. const INSTRUCTION_SUBJECTS_EXTRA = ['datatable'] as const -const ALL_INSTRUCTION_SUBJECTS = [ - ...INSTRUCTION_SUBJECTS, - ...INSTRUCTION_SUBJECTS_EXTRA -] as const +const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const const MAX_LIST_LIMIT = 100 type ActiveGlobalEditorType = Extract type LiveEditorDraftKind = Parameters[0] @@ -164,7 +161,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - "What to get authoring instructions for: a workspace item type (script, flow, resource, app) or \"datatable\" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don't need instructions — their tool schemas describe everything." + 'What to get authoring instructions for: a workspace item type (script, flow, resource, app) or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' ), language: scriptLangSchema .optional() @@ -954,7 +951,6 @@ type AppMetadata = { type LoadedAppDraftValue = { value: AppDraftValue - meta?: UserDraftMeta } function summarizeAppValue(value: AppDraftValue): AppMetadata { @@ -1095,21 +1091,14 @@ function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { } } -function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta { - return { - remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined, - remoteDraftRev: app.draft_created_at - } -} - async function loadAppValueForRead(path: string, workspace: string): Promise { const draft = getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } - const app = await AppService.getAppByPathWithDraft({ workspace, path }) - return appSourceToDraftValue(app.draft ?? app, app) + const app = await AppService.getAppByPath({ workspace, path }) + return appSourceToDraftValue(app, app) } async function loadAppDraftValue(path: string, workspace: string): Promise { @@ -1118,18 +1107,12 @@ async function loadAppDraftValue(path: string, workspace: string): Promise void } -} -| { - mode: 'simple' - original: Value - current: Value - title: string - button?: { text: string; onClick: () => void } -} +export type DiffDrawerDiff = + | { + mode: 'normal' + deployed: Value + draft?: Value | undefined + current: Value + defaultDiffType?: 'deployed' | 'draft' + button?: { text: string; onClick: () => void } + } + | { + mode: 'simple' + original: Value + current: Value + title: string + button?: { text: string; onClick: () => void } + } export interface DiffDrawerI { - openDrawer: () => void - closeDrawer: () => void - setDiff: (diff: DiffDrawerDiff) => void -} \ No newline at end of file + openDrawer: () => void + closeDrawer: () => void + setDiff: (diff: DiffDrawerDiff) => void +} diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 5d2493b930..5b8b018665 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -1,7 +1,7 @@ -import type { OpenFlow } from '$lib/gen' +import type { Flow, OpenFlow } from '$lib/gen' import type { StateStore } from '$lib/utils' import type { FlowState } from './flows/flowState' -import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { Trigger } from './triggers/utils' import type { DiffDrawerI } from './diff_drawer' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import type { ScheduleTrigger } from './triggers' @@ -17,14 +17,13 @@ export type FlowBuilderProps = { loading?: boolean flowStore: StateStore flowStateStore: StateStore - savedFlow?: FlowWithDraftAndDraftTriggers | undefined + savedFlow?: Flow | undefined diffDrawer?: DiffDrawerI | undefined customUi?: FlowBuilderWhitelabelCustomUi disableAi?: boolean disabledFlowInputs?: boolean savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore version?: number | undefined - setSavedraftCb?: ((cb: () => void) => void) | undefined draftTriggersFromUrl?: Trigger[] | undefined selectedTriggerIndexFromUrl?: number | undefined children?: import('svelte').Snippet @@ -34,23 +33,19 @@ export type FlowBuilderProps = { } noInitial?: boolean liveEditorDraftStoragePath?: string - onSaveInitial?: ({ path, id }: { path: string; id: string }) => void - onSaveDraft?: ({ - path, - savedAtNewPath, - newFlow - }: { - path: string - savedAtNewPath: boolean - newFlow: boolean - }) => void - onSaveDraftError?: ({ error }: { error: any }) => void - onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void onDeploy?: ({ path }: { path: string }) => void onDeployError?: ({ error }: { error: any }) => void onDetails?: ({ path }: { path: string }) => void onHistoryRestore?: () => void onNavigate?: (item: WorkspaceItem) => void + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the flow editor's + // indicator. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void // Fired whenever a test run is started from the flow editor, with the // preview job id. Used by whitelabel embedders to track test jobs. onTestJob?: (e: { jobId: string }) => void diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 5cc0df1502..fba509f8a9 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -26,10 +26,10 @@ // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), // so the in-memory importStore would be lost. Use sessionStorage instead. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) - await goto('/apps_raw/add?nodraft=true') + await goto('/apps_raw/add') } else { $importStore = parsed - await goto('/apps/add?nodraft=true') + await goto('/apps/add') } drawer?.closeDrawer?.() } @@ -40,12 +40,12 @@ function selectLowCode() { appTypeModalOpen = false - goto(`${base}/apps/add?nodraft=true`) + goto(`${base}/apps/add`) } function selectFullCode() { appTypeModalOpen = false - goto(`${base}/apps_raw/add?nodraft=true`) + goto(`${base}/apps_raw/add`) } diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index dd975fcc72..1539504ecc 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -33,7 +33,7 @@ async function importRaw() { $importFlowStore = importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '') - await goto('/flows/add?nodraft=true') + await goto('/flows/add') drawer?.closeDrawer?.() } @@ -41,13 +41,13 @@ const parsed = wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') $importScriptStore = parsed - await goto(`${base}/scripts/add?import=true&nodraft=true`) + await goto(`${base}/scripts/add?import=true`) wacDrawer?.closeDrawer?.() } function handleFlowClick() { if (skipModal) { - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } else { flowModalOpen = true } @@ -55,17 +55,17 @@ function selectFlowEditor() { flowModalOpen = false - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } function selectWacPython() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=python`) + goto(`${base}/scripts/add?wac=python`) } function selectWacTypescript() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + goto(`${base}/scripts/add?wac=typescript`) } function toggleSkipModal() { diff --git a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte index 30311e2fa3..0ce0843bbf 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte @@ -24,23 +24,14 @@ flowEditorDrawer?.openDrawer?.() try { - const flowWithDraft = await FlowService.getFlowByPathWithDraft({ + const backendFlow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path }) - savedFlow = { - ...structuredClone(flowWithDraft), - draft: flowWithDraft.draft - ? { - ...structuredClone(flowWithDraft.draft), - path: flowWithDraft.draft.path ?? flowWithDraft.path - } - : undefined - } as Flow & { draft?: Flow } + savedFlow = structuredClone(backendFlow) as Flow - // Use the draft if available, otherwise the deployed flow - flow = flowWithDraft.draft ?? flowWithDraft + flow = backendFlow await initFlow(flow, flowStore, flowStateStore) loading = false @@ -53,11 +44,7 @@ let callback: (() => void) | undefined = undefined let flowPath: string = $state('') let flow: Flow | undefined = $state(undefined) - let savedFlow: - | (Flow & { - draft?: Flow | undefined - }) - | undefined = $state(undefined) + let savedFlow: Flow | undefined = $state(undefined) let loading = $state(true) const flowStore: StateStore = $state({ diff --git a/frontend/src/lib/components/forkDraftToImport.ts b/frontend/src/lib/components/forkDraftToImport.ts new file mode 100644 index 0000000000..0ed9ea3efc --- /dev/null +++ b/frontend/src/lib/components/forkDraftToImport.ts @@ -0,0 +1,60 @@ +import { goto } from '$lib/navigation' +import { base } from '$app/paths' +import type { Flow, NewScript, UserDraftItemKind } from '$lib/gen' +import { importStore } from '$lib/components/apps/store' +import { importFlowStore } from '$lib/components/flows/flowStore.svelte' +import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' +import { getUsernameForNamespace } from '$lib/userNamespace' + +/** + * Re-home the source path into the forker's namespace: drop the first two + * segments, prefix `u/{me}`. `u/admin/myflow` → `u/me/myflow`. + */ +function forkSeedPath(sourcePath: string): string { + const rest = sourcePath.split('/').slice(2).join('/') + return `u/${getUsernameForNamespace()}/${rest}` +} + +/** + * Open a fetched draft value as a brand-new item of `itemKind`, via the same + * one-shot import handoff as "Import from YAML/JSON": stash the payload in the + * import store and route to the kind's `/add` page. The fork behaves like a new + * item of one's own — nothing saved until the first edit, no source identity + * carried over. The re-homed source path travels as `?seed_path=` (not `?path=`, + * which ScriptBuilder strips in transit) so the Path widget starts recognizable. + * Only the cross-user-visible kinds can be forked. + */ +export function forkDraftToImport( + itemKind: UserDraftItemKind, + value: unknown, + sourcePath: string +): void { + const seed = `?seed_path=${encodeURIComponent(forkSeedPath(sourcePath))}` + switch (itemKind) { + case 'script': + importScriptStore.set(value as NewScript) + goto(`${base}/scripts/add${seed}`) + return + case 'flow': + importFlowStore.set(value as Flow) + goto(`${base}/flows/add${seed}`) + return + case 'app': + // App drafts store the bare `App` value (no summary/policy + // wrapper) — the /apps/edit import branch accepts both shapes. + importStore.set(value as any) + goto(`${base}/apps/add${seed}`) + return + case 'raw_app': { + // Raw-app drafts bundle `{files, runnables, data, summary, + // policy, ...}` flat; wrap so the /apps_raw/edit import branch + // picks up summary and policy alongside the value. + const v = value as any + importStore.set({ summary: v?.summary ?? '', value: v, policy: v?.policy }) + goto(`${base}/apps_raw/add${seed}`) + return + } + default: + throw new Error(`Cannot fork drafts of kind ${itemKind}`) + } +} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 80b04fa455..8c821bb152 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -61,7 +61,6 @@ type?: U time?: number starred?: boolean - has_draft?: boolean hash?: string } @@ -241,9 +240,13 @@ async function showCode(path: string, summary: string) { viewCodeTitle = summary || path await viewCodeDrawer?.openDrawer() + // `getDraft: true` so draft-only scripts (no deployed row at this + // path) still return their content via the per-user draft overlay + // instead of 404'ing. script = await ScriptService.getScriptByPath({ workspace: $workspaceStore!, - path + path, + getDraft: true }) } diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index 3196621a4d..1bbe013e46 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -5,7 +5,6 @@ type TableItem = T & { type?: U time?: number starred?: boolean - has_draft?: boolean } type TableScript = TableItem diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index b62e047e84..adafc8256a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -43,6 +43,7 @@ import { runScriptAndPollResult } from '../jobs/utils' import { RawAppHistoryManager } from './RawAppHistoryManager.svelte' import { sendUserToast } from '$lib/utils' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { buildDataTableWhitelist, parseDataTableRef, @@ -78,9 +79,6 @@ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** Fired after a successful deploy; the session preview reloads on it. */ onDeploy?: (e: { path: string }) => void - /** Fired after a successful server-draft save; the session refreshes its - * draft-bar count on it (parity with the script/flow editors). */ - onSaveDraft?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -98,6 +96,15 @@ * still toggle the mode after mount; this prop only seeds the * initial state. */ defaultSplitWithPreview?: boolean + /** User-typed path when it differs from `savedApp.path`. The route injects + * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ + pendingDraftPath?: string | undefined + // Threaded to the AutosaveIndicator's "Reset to deployed" button. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same indicator semantics. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void onRuntimeLogRequester?: (requester: RawAppRuntimeLogRequester | undefined) => void onRunsProvider?: (provider: RawAppRunsProvider | undefined) => void } @@ -115,11 +122,15 @@ diffDrawer = undefined, onNavigate, onDeploy = undefined, - onSaveDraft = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, defaultSplitWithPreview = true, + pendingDraftPath = $bindable(undefined), + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts, onRuntimeLogRequester = undefined, onRunsProvider = undefined }: Props = $props() @@ -1396,7 +1407,51 @@ return () => window.removeEventListener('keydown', onEscapeCapture, true) }) + // Force an immediate flush. No toast — the AutosaveIndicator narrates the + // result, and `flush` never rejects (postSave routes errors to the failures map). + function flushDraft() { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + void UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: liveEditorDraftStoragePath + }) + } + + // The VS Code workbench iframe's keydowns don't bubble out, so the window + // handler can't see Ctrl/Cmd+S while editing code. Attach a capture listener + // inside the iframe per load (it dies with the iframe, so no leak). No + // preventDefault: VS Code's own save still runs; we just flush alongside it. + function attachIframeSaveShortcut() { + const win = iframe?.contentWindow + if (!win) return + win.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { + flushDraft() + } + }, + true + ) + } + + // Monaco swallows Ctrl/Cmd+S in inline editors; Editor/SimpleEditor + // re-broadcast it as `wm-monaco-save-shortcut` (untyped, hence manual listener). + $effect(() => { + window.addEventListener('wm-monaco-save-shortcut', flushDraft) + return () => window.removeEventListener('wm-monaco-save-shortcut', flushDraft) + }) + function handleKeydown(e: KeyboardEvent) { + // Ctrl/Cmd + S — catch this BEFORE the input/Monaco guard below so + // the shortcut fires regardless of focus. + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { + e.preventDefault() + flushDraft() + return + } + // Skip when typing in an input, textarea, or Monaco editor. const classes = (e.target as HTMLElement | null)?.className if ( @@ -1439,6 +1494,7 @@ bind:jobsById bind:savedApp bind:summary + bind:pendingDraftPath on:restore on:savedNewAppPath {policy} @@ -1453,7 +1509,10 @@ {getBundle} {onNavigate} {onDeploy} - {onSaveDraft} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1592,6 +1651,7 @@ title="UI builder" src="/ui_builder/index.html" class="w-full h-full block" + onload={attachIframeSaveShortcut} > {/if}
diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 46dec152f7..e21d1cd914 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -5,8 +5,9 @@ import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' - import { AppService, DraftService, type Policy } from '$lib/gen' + import { AppService, type Policy } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' + import { discardDraftAfterDeploy } from '$lib/userDraftToast' import { rawAppToHubUrl } from '$lib/hub' import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import YAML from 'yaml' @@ -26,32 +27,25 @@ WandSparkles } from 'lucide-svelte' import { createEventDispatcher, untrack } from 'svelte' - import { - cleanValueProperties, - orderedJsonStringify, - type Value, - replaceFalseWithUndefined - } from '../../utils' + import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils' import { random_adj } from '$lib/components/random_positive_adjetive' // import { allItems, toStatic } from '../apps/editor/settingsPanel/utils' import AppExportButton from '../apps/editor/AppExportButton.svelte' - import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte' import Awareness from '$lib/components/Awareness.svelte' import type DiffDrawer from '$lib/components/DiffDrawer.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' + import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte' import { goto } from '$app/navigation' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte' - import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes' import DropdownV2 from '../DropdownV2.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' - import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte' import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte' import type { Runnable } from './RawAppInlineScriptRunnable.svelte' import { updateRawAppPolicy } from './rawAppPolicy' @@ -103,11 +97,9 @@ savedApp?: | { value: any - draft?: any path: string summary: string policy: any - draft_only?: boolean custom_path?: string } | undefined @@ -136,9 +128,20 @@ liveEditorDraftStoragePath?: string // Fired after a successful deploy; lets the session preview reload. onDeploy?: (e: { path: string }) => void - // Fired after a successful server-draft save; lets the session refresh the - // draft-bar count (the script/flow editors do the same on save-draft). - onSaveDraft?: (e: { path: string }) => void + /** Surfaces the user-typed path (`newEditedPath`) up to the route + * when (and only when) it differs from the deployed/seeded + * `savedApp.path`. The route writes it into the autosaved raw-app + * draft as `draft_path` so the home-page row can render the + * friendly name instead of the URL's autogenerated draft slot. */ + pendingDraftPath?: string | undefined + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the raw-app editor's + // indicator. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void } let { @@ -166,9 +169,21 @@ onNavigate = undefined, liveEditorDraftStoragePath = undefined, onDeploy = undefined, - onSaveDraft = undefined + pendingDraftPath = $bindable(undefined), + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts }: Props = $props() + $effect(() => { + const typed = newEditedPath + const baseline = savedApp?.path ?? '' + untrack(() => { + pendingDraftPath = typed && typed !== baseline ? typed : undefined + }) + }) + let newEditedPath = $state( untrack(() => newApp @@ -202,14 +217,12 @@ const loading = $state({ publish: false, - save: false, - saveDraft: false + save: false }) let pathError: string = $state('') let appExport = $state() as AppExportButton | undefined - let draftDrawerOpen = $state(false) let saveDrawerOpen = $state(false) let historyBrowserDrawerOpen = $state(false) let publishToHubDrawerOpen = $state(false) @@ -255,10 +268,6 @@ saveDrawerOpen = false } - function closeDraftDrawer() { - draftDrawerOpen = false - } - async function computeTriggerables() { policy = await updateRawAppPolicy(runnables, policy) } @@ -301,7 +310,19 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - if (!inSessionPane) UserDraft.remove('raw_app', path) + // Canonical autosave key (the URL slot `appPath`), NOT the + // just-typed deploy `path` — for a draft-only app they differ + // (`u/{user}/draft_{uuid}` vs the chosen path), so removing at + // `path` orphaned the real draft row. Bracketed + flushed: + // RawAppEditor stays mounted through the post-deploy navigation + // and its mirror would otherwise displace the queued delete. + if (!inSessionPane && $workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: appPath + }) + } dispatch('savedNewAppPath', path) onDeploy?.({ path }) } catch (e) { @@ -330,7 +351,7 @@ replaceFalseWithUndefined({ summary: summary, value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, + path: newEditedPath || savedApp.path, policy, custom_path: customPath }) @@ -380,11 +401,10 @@ diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedApp, - draft: savedApp.draft, current: { summary: summary, value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, + path: newEditedPath || savedApp.path, policy, custom_path: customPath } @@ -436,7 +456,14 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - if (!inSessionPane) UserDraft.remove('raw_app', appPath) + // Bracketed + flushed (see createApp). + if (!inSessionPane && $workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: appPath + }) + } if (appPath !== npath) { dispatch('savedNewAppPath', npath) } @@ -462,185 +489,6 @@ return } - async function saveInitialDraft() { - if (!app) { - sendUserToast(`App hasn't been loaded yet`, true) - return - } - await computeTriggerables() - try { - let { css, js } = await getBundle() - await AppService.createAppRaw({ - workspace: $workspaceStore!, - formData: { - app: { - value: app, - path: newEditedPath, - summary: summary, - policy, - draft_only: true, - custom_path: customPath - }, - js, - css - } - }) - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: newEditedPath, - typ: 'app', - value: { - value: app, - path: newEditedPath, - summary: summary, - policy, - custom_path: customPath - } - } - }) - savedApp = { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath, - policy, - draft_only: true, - draft: { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath, - policy, - custom_path: customPath - }, - custom_path: customPath - } - - draftDrawerOpen = false - // The initial draft was promoted to a real path on the backend — - // drop the autosave keyed on the prior (possibly empty) path so - // a future "+ App" click opens on a clean slate. - if (!inSessionPane) UserDraft.remove('raw_app', appPath) - dispatch('savedNewAppPath', newEditedPath) - sendUserToast('Draft saved') - onSaveDraft?.({ path: newEditedPath }) - } catch (e) { - sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true) - } - draftDrawerOpen = false - } - - async function saveDraft(forceSave = false) { - if (!app) { - sendUserToast(`App hasn't been loaded yet`, true) - return - } - if (newApp) { - if (appPath === '') { - // Standalone "+ App" with no path chosen yet — pick one via the drawer. - draftDrawerOpen = true - return - } - // Path already known (e.g. an AI-created raw app in the session preview). - // The path-picker drawer is gated on `appPath == ''`, so opening it here - // renders nothing — save the initial draft directly instead. - await saveInitialDraft() - return - } - if (!savedApp) { - return - } - const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp) - const current = cleanValueProperties({ - summary: summary, - value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, - policy - }) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraft(true) - } - } - ]) - return - } - loading.saveDraft = true - try { - await computeTriggerables() - let path = appPath - if (savedApp.draft_only) { - await AppService.deleteApp({ - workspace: $workspaceStore!, - path: path - }) - let { css, js } = await getBundle() - - await AppService.createAppRaw({ - workspace: $workspaceStore!, - formData: { - app: { - value: app!, - summary: summary, - policy, - path: newEditedPath || path, - draft_only: true, - custom_path: customPath - }, - js, - css - } - }) - } - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: savedApp.draft_only ? newEditedPath || path : path, - typ: 'app', - value: { - value: app!, - summary: summary, - policy, - path: newEditedPath || path - } - } - }) - - savedApp = { - ...(savedApp?.draft_only - ? { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: savedApp.draft_only ? newEditedPath || path : path, - policy, - draft_only: true, - custom_path: customPath - } - : savedApp), - draft: { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath || path, - policy, - custom_path: customPath - } - } - - sendUserToast('Draft saved') - if (!inSessionPane) UserDraft.remove('raw_app', path) - loading.saveDraft = false - if (newApp || savedApp.draft_only) { - dispatch('savedNewAppPath', newEditedPath || path) - } - onSaveDraft?.({ path: newEditedPath || path }) - } catch (e) { - loading.saveDraft = false - throw e - } - } - let onLatest = $state(true) async function compareVersions() { if (version === undefined) { @@ -663,13 +511,6 @@ let moreItems = $derived([ ...(compactTopbar ? [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: !newApp && !savedApp - }, { displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`, icon: Bug, @@ -729,18 +570,6 @@ let jobsDrawerOpen = $state(false) - function getInitialAndModifiedValues(): SavedAndModifiedValue { - return { - savedValue: savedApp, - modifiedValue: { - summary: summary, - value: app, - path: newEditedPath || savedApp?.draft?.path || savedApp?.path, - policy, - custom_path: customPath - } - } - } let app = $derived(files ? { runnables: runnables, files, data } : undefined) $effect(() => { @@ -748,14 +577,6 @@ }) - -{#if !inSessionPane} - -{/if} - -{#if appPath == ''} - - closeDraftDrawer()}> - {#snippet actions()} -
- -
- {/snippet} - -
-
-{/if} closeSaveDrawer()}> {#snippet actions()}
{#if $enterpriseLicense && appPath != ''} @@ -979,9 +786,9 @@ variant="default" unifiedSize="md" on:click={() => openDiffDrawer()} - disabled={!savedApp} + disabled={!savedApp || newApp} iconOnly={compactTopbar} - title="Diff" + title={newApp ? 'Deploy this app once to compare against the deployed version' : 'Diff'} startIcon={{ icon: DiffIcon }} > Diff @@ -1020,19 +827,6 @@ AI {/if} - {#if !compactTopbar} - - {/if} + {/each} +
+
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended. +
+ {#if $userStore?.is_admin} + Configure datatables in + workspace settings + to enable this feature. + {:else} + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. + {/if} +
+ {:else} +
+
+ Default settings for new tables +
+
+
+ + +
+ {/if} +
+ {#if newSchemaAlreadyExists} + Schema "{newSchemaName}" already exists + {/if} +
+
+
+
+ +
+ +
+ +
+ dataTableDrawer?.openDrawer()} + onRemove={(index) => { + preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) + }} + /> +
+
+ {/if} +
+ +
+

+ + Start with AI + (optional) +

+ + {#if !isAiEnabled} + + You can still create an app manually but using AI is highly recommended. +
+ {#if $userStore?.is_admin} + Configure AI in + workspace settings + to enable this feature. + {:else} + Ask your workspace admin to configure AI in workspace settings to enable this feature. + {/if} +
+ {:else} +
+ +

+ Leave empty to start with a blank template, or describe your app to get AI assistance + right away. +

+
+ {/if} +
+ +
+ + {#if isAiEnabled} + + {/if} +
+
+ +{/if} + + { + preWhitelistedTables = [...preWhitelistedTables, ref] + }} +/> diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index cac47d4eef..2bd16dd5c8 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -1,10 +1,10 @@ -import type { NewScript } from '$lib/gen' +import type { NewScript, Script } from '$lib/gen' import type { AssetWithAltAccessType } from './assets/lib' import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui' import type { DiffDrawerI } from './diff_drawer' import type { ScriptBuilderFunctionExports } from './scriptBuilder' import type { ScheduleTrigger } from './triggers' -import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { Trigger } from './triggers/utils' import type { WorkspaceItem } from './workspacePicker' export interface ScriptBuilderProps { @@ -15,6 +15,18 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string + /** + * Path the route's `UserDraft.use('script', ...)` + * handle is keyed by. Distinct from `initialPath` for new drafts — + * `initialPath` is the displayed/editor path (empty for new), while + * this is the URL path the draft is persisted under (`u/{user}/ + * draft_{uuid}`). Used to bracket the bootstrap `initContent` write + * with `UserDraft.stopSync` / `restartSync` so the template seed + * doesn't POST before the user's first real edit. Default to `''` + * for backwards compat with callers that don't manage drafts; the + * stop/restart pair is a no-op on a non-live entry. + */ + userDraftPath?: string template?: | 'docker' | 'bunnative' @@ -29,7 +41,7 @@ export interface ScriptBuilderProps { showMeta?: boolean neverShowMeta?: boolean diffDrawer?: DiffDrawerI | undefined - savedScript?: NewScriptWithDraftAndDraftTriggers | undefined + savedScript?: Script | NewScript | undefined searchParams?: URLSearchParams disableHistoryChange?: boolean customUi?: ScriptBuilderWhitelabelCustomUi @@ -41,12 +53,8 @@ export interface ScriptBuilderProps { // the deployed item) — consumers should skip post-deploy navigation when set. onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void onDeployError?: (e: { path: string; error: any }) => void - onSaveInitial?: (e: { path: string; hash: string }) => void onHistoryRestore?: () => void - onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void - onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void onSeeDetails?: (e: { path: string }) => void - onSaveDraftError?: (e: { path: string; error: any }) => void onNavigate?: (item: WorkspaceItem) => void // Fired whenever a test run is started from the script editor, with the // preview job id. Used by whitelabel embedders to track test jobs. @@ -59,4 +67,19 @@ export interface ScriptBuilderProps { // overwrite it. Used by the session preview, which opens AI-created scripts // as new but with a path the AI already assigned. initialPathChosen?: boolean + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + // Routes pass their own re-load-without-draft callback here; omit on + // callers (session preview, embedded SDK) that shouldn't surface the + // action at all. + onResetToDeployed?: () => void | Promise + // Triggers the AutosaveIndicator's on-mount "Loaded from draft" hint + // (with a one-shot green flash) the first time it flips to true. + loadedFromDraft?: boolean + // Non-zero when other workspace users have a draft at this path. + // Drives both the indicator's hint label ("Others are working on + // this script") and the popover's "See others' drafts" button. + othersDraftsCount?: number + // Wired by the route to flip the OtherUsersDraftsModal open. + onOpenOthersDrafts?: () => void } diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 5abd8235f2..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -14,7 +14,7 @@ unifiedSize="lg" variant="accent" startIcon={{ icon: Plus }} - href="{base}/scripts/add?nodraft=true" + href="{base}/scripts/add" endIcon={{ icon: Code2 }} > Script diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index 21b79ea6b9..19e71530e6 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -472,7 +472,6 @@ type?: U time?: number starred?: boolean - has_draft?: boolean } // interface SelectableSearchMenuItem { diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index 9613b4c25a..6dc761fdc8 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -66,11 +66,6 @@ {diffDrawer} {onNavigate} customUi={{ topBar: { aiBuilder: false } }} - onSaveDraft={() => { - runtime.scheduleForkComparisonRefresh() - // Saving a draft adds/keeps a pending draft — refresh the Draft Count. - invalidateWorkspaceDrafts(workspaceId) - }} onDeploy={() => { // FlowBuilder has no deploy toast and the session stays put, so toast // here, then sync the preview to deployed (pulls the new locks + version_id). diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 8b2d435daa..ca21b9be03 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -78,11 +78,6 @@ // Deploying clears the item's pending draft — refresh the Draft Count. invalidateWorkspaceDrafts(workspaceId) }} - onSaveDraft={() => { - // Saving a server draft adds/updates a draft — refresh the Draft Count so - // the session draft bar appears/updates immediately (parity with script/flow). - invalidateWorkspaceDrafts(workspaceId) - }} defaultSidebarCollapsed sidebarStorageKey="raw-app-sidebar-collapsed-preview" defaultSplitWithPreview={false} diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index 2e6fc4210f..88b8e4da44 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -3,8 +3,9 @@ import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionRuntime } from './sessionRuntime.svelte' - import { DraftService, ScriptService, type NewScript } from '$lib/gen' + import type { NewScript } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' @@ -41,18 +42,28 @@ return } diffDrawer?.closeDrawer() - // Drop the backend (DB) draft too, so "deployed" sticks across a reload. - if (saved.draft) { - try { - await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path }) - saved.draft = undefined - // Server draft gone — refresh the session draft-bar count immediately - // instead of waiting for an AI turn-end / tab-refocus signal. - invalidateWorkspaceDrafts(workspaceId) - } catch (e: any) { - sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true) - return - } + // Drop the user's per-user draft too, so "deployed" sticks across + // a reload. The overlay sets `is_draft: true` when a draft exists + // for the authed user; the syncer's `value: null` POST is the + // canonical per-user delete. + // + // Fire-and-forget: every read here (`saved`, the snapshot we build + // below, the UserDraft.discard write) is purely in-memory, so we + // don't need the DELETE to have landed to finish the restore. We + // flip `is_draft` optimistically so the UI matches the new intent + // immediately. A failed DELETE only matters across a hard reload + // before it lands — log and move on. + if (saved.is_draft) { + saved.is_draft = false + UserDraftDbSyncer.save({ + workspace: workspaceId, + itemKind: 'script', + path: saved.path, + value: null + }).catch((e) => console.error('restoreDeployed: draft delete failed', e)) + // Per-user draft gone — refresh the session draft-bar count immediately + // instead of waiting for an AI turn-end / tab-refocus signal. + invalidateWorkspaceDrafts(workspaceId) } const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown } delete deployed.draft @@ -105,24 +116,6 @@ {diffDrawer} {onNavigate} {initialTestPanelCollapsed} - onSaveDraft={async (e) => { - runtime.scheduleForkComparisonRefresh() - // Saving a draft adds/keeps a pending draft — refresh the Draft Count. - invalidateWorkspaceDrafts(workspaceId) - // Re-pin parent_hash to the latest version so the next Deploy's conflict - // check (which runs before deploy, while the session stays mounted) - // doesn't misfire. - try { - const latest = await ScriptService.getScriptLatestVersion({ - workspace: workspaceId, - path: e.path - }) - const cur = runtime.scriptStore.val - if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash - } catch (err) { - console.error('Failed to sync parent_hash after save draft', err) - } - }} onDeploy={(e) => { // Fires on every deploy (primary, "Deploy & Stay here", and lib — we // ignore e.stay since the session always stays). Toast, then sync the diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 5509c1dc25..bfeddd1f66 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -9,9 +9,20 @@ import { WorkspaceService, type Flow, type NewScript, - type NewScriptWithDraft, + type Script, + type UserDraftOverlay, type WorkspaceComparison } from '$lib/gen' + +// `get_draft=true` does NOT merge: the top-level fields stay the deployed +// payload and the user's draft rides in a sibling `.draft` pocket (with +// `is_draft` / `draft_saved_at` alongside). Hence the `saved.draft ?? saved` +// fall-throughs below prefer the draft, else the deployed payload. +// The generated `UserDraftOverlay` types `draft` permissively; locally it's a +// `NewScript` / `Flow`, so `Omit` and re-add the precise type (assignments from +// the response type still need an explicit cast). +type SavedScript = Omit - -{#if value} -
- {#key value} - { - goto(`/apps/edit/${path}`) - }} - {summary} - app={value} - path={''} - {policy} - fromHub={hubId != null} - newApp={true} - replaceStateFn={(path) => replaceState(path, page.state)} - gotoFn={(path, opt) => goto(path, opt)} - > - {#snippet unsavedConfirmationModal({ - diffDrawer, - additionalExitAction, - getInitialAndModifiedValues - })} - - {/snippet} - - {/key} -
-{/if} + diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps/add/+page.ts new file mode 100644 index 0000000000..52ecb242b7 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index e2e1029d0e..2060acb14e 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -1,227 +1,252 @@ - - + { + // AppEditor's `stateApp` is captured at mount and ignores prop changes, + // so `redraw++` remounts it against the fresh `app`. + await loadApp() + redraw++ + }} + getLocalDraft={() => app?.value} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'app', + path, + onResetToDeployed: async () => { + UserDraft.remove('app', path) + await loadApp({ getDraft: false }) + redraw++ + } + }) + }} /> {#key redraw} {#if app}
{ goto(`/apps/edit/${url}`) if (app) { @@ -308,29 +349,24 @@ on:restore={onRestore} summary={app.summary} app={app.value} - newPath={app.path} + newPath={app.value?.draft_path ?? app.path} path={page.params.path ?? ''} policy={app.policy} bind:savedApp {diffDrawer} version={app.versions ? app.versions[app.versions.length - 1] : undefined} - newApp={false} - initialRevs={currentRevs} + newApp={isNewApp} replaceStateFn={(path) => replaceState(path, page.state)} gotoFn={(path, opt) => goto(path, opt)} - > - {#snippet unsavedConfirmationModal({ - diffDrawer, - additionalExitAction, - getInitialAndModifiedValues - })} - - {/snippet} - + onResetToDeployed={async () => { + UserDraft.remove('app', path) + await loadApp({ getDraft: false }) + redraw++ + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} + />
{/if} {/key} diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 737ebb2e33..460b2957e0 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -81,7 +81,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps/edit/{app.path}?nodraft=true">EditEdit
{/if} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js deleted file mode 100644 index 1f2d07eb57..0000000000 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js +++ /dev/null @@ -1,5 +0,0 @@ -export function load() { - return { - stuff: { title: `New Raw App` } - } -} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 716000215f..e54568a9ed 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -1,688 +1,5 @@ - - -{#if templatePicker} - -
- -
-

Summary

- -
- - -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- - -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- - {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable this - feature. - {/if} -
- {:else} -
- -
- Default settings for new tables -
-
-
- - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists - {/if} -
-
-
-
- - -
- -
- - -
- dataTableDrawer?.openDrawer()} - onRemove={(index) => { - preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) - }} - /> -
-
- {/if} -
- - -
-

- - Start with AI - (optional) -

- - {#if !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. -
- {#if $userStore?.is_admin} - Configure AI in - workspace settings - - to enable this feature. - {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. - {/if} -
- {:else} -
- -

- Leave empty to start with a blank template, or describe your app to get AI assistance - right away. -

-
- {/if} -
- - -
- - {#if isAiEnabled} - - {/if} -
-
- -{/if} -{#key reloadCounter} - { - goto(`/apps_raw/edit/${event.detail}`) - }} - bind:files - bind:runnables - bind:data - {policy} - path={''} - liveEditorDraftStoragePath="" - bind:summary - newApp - /> -{/key} - - { - preWhitelistedTables = [...preWhitelistedTables, ref] - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts new file mode 100644 index 0000000000..ea96e67846 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps_raw/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 84ce23e7a5..3fc657c3ae 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -1,15 +1,9 @@ - - + loadApp()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadApp({ getDraft: false }) + } + }) + }} /> + + {#if files} {#key redraw}
{ - UserDraft.remove('raw_app', path) + draftSync.remove() goto(`/apps_raw/edit/${event.detail}`) newPath = event.detail }} @@ -392,13 +433,21 @@ bind:runnables bind:data bind:summary + bind:pendingDraftPath {newPath} path={page.params.path ?? ''} liveEditorDraftStoragePath={path} {policy} bind:savedApp {diffDrawer} - newApp={false} + newApp={isNewApp} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadApp({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} />
{/key} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index eefa19fcd3..9fdf4c5c7d 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -58,7 +58,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps_raw/edit/{page.params.path}?nodraft=true">EditEdit {/if} diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index a8d0b566b8..2d03b85398 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/edit/${e.path}?selected=${e.id}`) - }} - onDeploy={(e) => { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onDetails={(e) => { - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - {initialPath} - {pathStoreInit} - liveEditorDraftStoragePath="" - bind:this={flowBuilder} - newFlow - {initialArgs} - {flowStore} - {flowStateStore} - {selectedId} - {loading} - {draftTriggersFromUrl} - {selectedTriggerIndexFromUrl} - noInitial -> - - + diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.ts b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts new file mode 100644 index 0000000000..153c2255df --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('flows/edit') diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index b979a09758..1e1fcdccbc 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -1,35 +1,30 @@ - - + loadFlow()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + } + }) + }} /> {#if notFound}
@@ -404,25 +441,36 @@ {:else if renderEditor} { - UserDraft.remove('flow', flowDraftPath) + // stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} - onSaveDraftOnlyAtNewPath={(e) => { - goto(`/flows/edit/${e.path}?selected=${e.selectedId}`) - }} onHistoryRestore={() => { loadFlow() }} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} - initialPath={page.params.path ?? ''} + bind:initialPath={flowInitialPath} liveEditorDraftStoragePath={flowDraftPath} - newFlow={false} + newFlow={isNewFlow} {selectedId} {initialArgs} {loading} @@ -434,10 +482,5 @@ {selectedTriggerIndexFromUrl} {version} {loadedFromHistoryFromUrl} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 088563b640..8741a8b93b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -329,7 +329,7 @@ onClick: async () => { const app = createAppFromFlow(flow.path, flow.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, unifiedSize: 'md', variant: 'subtle', @@ -341,7 +341,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: `${base}/flows/edit/${path}?nodraft=true`, + href: `${base}/flows/edit/${path}`, variant: 'accent', unifiedSize: 'md', disabled: !can_write || !showEditButtons, diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index bb65a27065..6e59e7be5e 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - -{#if scriptHandle.draft} - { - // "Deploy & Stay here" / lib: stay on the editor (just confirm). - if (e.stay) { - sendUserToast('Deployed') - return - } - goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) - }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - searchParams={page.url.searchParams} - bind:script={scriptHandle.draft} - {showMeta} - > - - -{:else} - -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts new file mode 100644 index 0000000000..05237ef0db --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('scripts/edit') diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index b76c03a804..f04ef93188 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -1,95 +1,40 @@ - - + loadScript()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + } + }) + }} /> - - -{#if scriptHandle.draft && renderEditor} +{#if draftSync.draft && renderEditor} (othersModalOpen = true)} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + }} onDeploy={(e) => { // "Deploy & Stay here" / lib: stay on the editor (just confirm). if (e.stay) { sendUserToast('Deployed') return } - UserDraft.remove('script', draftPath) + // stopSync-bracketed immediate delete; see restoreDeployed for the race. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index df37a946f9..24bf997c50 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -422,7 +422,7 @@ onClick: async () => { const app = createAppFromScript(script.path, script.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, disabled: !showEditButtons, unifiedSize: 'md', diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index b70730bd8c..586d2077d2 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -1,4 +1,5 @@