diff --git a/backend/.sqlx/query-0035bf99ce6fc00c7338bebfeb7e79bb9e7bc3d216b84279dee0018603965941.json b/backend/.sqlx/query-0035bf99ce6fc00c7338bebfeb7e79bb9e7bc3d216b84279dee0018603965941.json deleted file mode 100644 index cde17acf76..0000000000 --- a/backend/.sqlx/query-0035bf99ce6fc00c7338bebfeb7e79bb9e7bc3d216b84279dee0018603965941.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH del AS (\n DELETE FROM v2_job_debounce_batch\n WHERE consumed_at IS NOT NULL AND consumed_at < now() - interval '10 minutes'\n RETURNING 1\n ) SELECT count(*) FROM del", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "0035bf99ce6fc00c7338bebfeb7e79bb9e7bc3d216b84279dee0018603965941" -} diff --git a/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json b/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json deleted file mode 100644 index 5ca75fe782..0000000000 --- a/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM asset\n WHERE (workspace_id, path, kind) IN (\n SELECT workspace_id, path, kind FROM (\n SELECT a.workspace_id, a.path, a.kind, a.usage_kind, ROW_NUMBER() OVER (\n PARTITION BY a.workspace_id, a.path, a.kind\n ORDER BY a.created_at DESC\n ) as rn,\n limits.max_n\n FROM asset a\n INNER JOIN (\n SELECT * FROM UNNEST(\n $1::varchar[], \n $2::varchar[], \n $3::asset_kind[],\n $4::int[]\n ) AS t(workspace_id, path, kind, max_n)\n ) limits\n ON a.workspace_id = limits.workspace_id \n AND a.path = limits.path \n AND a.kind = limits.kind\n WHERE a.usage_kind = 'job'\n ) ranked\n WHERE rn > max_n\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "VarcharArray", - "VarcharArray", - { - "Custom": { - "name": "asset_kind[]", - "kind": { - "Array": { - "Custom": { - "name": "asset_kind", - "kind": { - "Enum": [ - "s3object", - "resource", - "variable", - "ducklake", - "datatable", - "volume" - ] - } - } - } - } - } - }, - "Int4Array" - ] - }, - "nullable": [] - }, - "hash": "02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0" -} diff --git a/backend/.sqlx/query-40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e.json b/backend/.sqlx/query-0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b.json similarity index 51% rename from backend/.sqlx/query-40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e.json rename to backend/.sqlx/query-0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b.json index 6fd7d38f69..036e8e4b57 100644 --- a/backend/.sqlx/query-40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e.json +++ b/backend/.sqlx/query-0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2", + "query": "UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e" + "hash": "0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b" } diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index e7ed0aee65..d29a18c691 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true, true ] diff --git a/backend/.sqlx/query-1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346.json b/backend/.sqlx/query-1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346.json deleted file mode 100644 index 911d6c3b07..0000000000 --- a/backend/.sqlx/query-1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n CASE WHEN q.running\n THEN $3::text::jsonb\n ELSE $4::text::jsonb\n END,\n s.flow_status,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($5, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "x", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar", - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346" -} diff --git a/backend/.sqlx/query-165abf847773c7fe718fa6c832b259bf2dc531bc468d2ab27f76c9c653be0b9a.json b/backend/.sqlx/query-165abf847773c7fe718fa6c832b259bf2dc531bc468d2ab27f76c9c653be0b9a.json new file mode 100644 index 0000000000..f548727abb --- /dev/null +++ b/backend/.sqlx/query-165abf847773c7fe718fa6c832b259bf2dc531bc468d2ab27f76c9c653be0b9a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "165abf847773c7fe718fa6c832b259bf2dc531bc468d2ab27f76c9c653be0b9a" +} diff --git a/backend/.sqlx/query-16c7838ecfcea5fd231f2a4766f691a9a11ca7bb9797b81444419d6a72883531.json b/backend/.sqlx/query-16c7838ecfcea5fd231f2a4766f691a9a11ca7bb9797b81444419d6a72883531.json new file mode 100644 index 0000000000..058018a8ee --- /dev/null +++ b/backend/.sqlx/query-16c7838ecfcea5fd231f2a4766f691a9a11ca7bb9797b81444419d6a72883531.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (parent_workspace_id IS NOT NULL) AS \"has_parent!\", is_dev_workspace\n FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_parent!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_dev_workspace", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + false + ] + }, + "hash": "16c7838ecfcea5fd231f2a4766f691a9a11ca7bb9797b81444419d6a72883531" +} diff --git a/backend/.sqlx/query-1c5caaaa86e3488549cad179e992172315be5d53dcc266d713da01fd27f310b6.json b/backend/.sqlx/query-1c5caaaa86e3488549cad179e992172315be5d53dcc266d713da01fd27f310b6.json deleted file mode 100644 index 75957a4f2f..0000000000 --- a/backend/.sqlx/query-1c5caaaa86e3488549cad179e992172315be5d53dcc266d713da01fd27f310b6.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - { - "Custom": { - "name": "asset_usage_kind", - "kind": { - "Enum": [ - "script", - "flow", - "job" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "1c5caaaa86e3488549cad179e992172315be5d53dcc266d713da01fd27f310b6" -} diff --git a/backend/.sqlx/query-1cd7c77e7a6a5c13c4ca521098bb07c1d805d21899fe0ebac22132b248ffd242.json b/backend/.sqlx/query-1cd7c77e7a6a5c13c4ca521098bb07c1d805d21899fe0ebac22132b248ffd242.json new file mode 100644 index 0000000000..b84cd8a14e --- /dev/null +++ b/backend/.sqlx/query-1cd7c77e7a6a5c13c4ca521098bb07c1d805d21899fe0ebac22132b248ffd242.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (workspace_id, name)\n DO UPDATE SET rules = EXCLUDED.rules,\n bypass_groups = EXCLUDED.bypass_groups,\n bypass_users = EXCLUDED.bypass_users\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int4", + "TextArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "1cd7c77e7a6a5c13c4ca521098bb07c1d805d21899fe0ebac22132b248ffd242" +} diff --git a/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json b/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json index a53d131a3f..f9bc97d29d 100644 --- a/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json +++ b/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json @@ -38,7 +38,8 @@ "google", "ci_test", "github", - "azure" + "azure", + "asset" ] } } @@ -75,7 +76,8 @@ "google", "ci_test", "github", - "azure" + "azure", + "asset" ] } } diff --git a/backend/.sqlx/query-299b94a7972443267dd664c178a1704d195a7fc0d4e66e1014a18398e3a294f4.json b/backend/.sqlx/query-299b94a7972443267dd664c178a1704d195a7fc0d4e66e1014a18398e3a294f4.json deleted file mode 100644 index 642723decf..0000000000 --- a/backend/.sqlx/query-299b94a7972443267dd664c178a1704d195a7fc0d4e66e1014a18398e3a294f4.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH del AS (\n DELETE FROM v2_job_debounce_batch\n WHERE consumed_at IS NOT NULL AND consumed_at < now() - interval '10 minutes'\n RETURNING 1\n ) SELECT count(*) as \"c!\" FROM del", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "c!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "299b94a7972443267dd664c178a1704d195a7fc0d4e66e1014a18398e3a294f4" -} diff --git a/backend/.sqlx/query-29eb2c40e13d6e1ff7c37a05ab107829242f015a331bf15600986be3963878ee.json b/backend/.sqlx/query-29eb2c40e13d6e1ff7c37a05ab107829242f015a331bf15600986be3963878ee.json new file mode 100644 index 0000000000..3ad3666413 --- /dev/null +++ b/backend/.sqlx/query-29eb2c40e13d6e1ff7c37a05ab107829242f015a331bf15600986be3963878ee.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5\n FROM workspace WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "29eb2c40e13d6e1ff7c37a05ab107829242f015a331bf15600986be3963878ee" +} diff --git a/backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json b/backend/.sqlx/query-2ec9f88ad80d192a2066764222fdfed7c553de4df8e876ff8589738dea93d0cf.json similarity index 71% rename from backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json rename to backend/.sqlx/query-2ec9f88ad80d192a2066764222fdfed7c553de4df8e876ff8589738dea93d0cf.json index 50ed7549e8..6163a0f9fb 100644 --- a/backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json +++ b/backend/.sqlx/query-2ec9f88ad80d192a2066764222fdfed7c553de4df8e876ff8589738dea93d0cf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2", + "query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM ws_specific ws\n WHERE ws.path = workspace_diff.path\n AND ws.item_kind = workspace_diff.kind\n AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id)\n )", "describe": { "columns": [ { @@ -55,5 +55,5 @@ true ] }, - "hash": "0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1" + "hash": "2ec9f88ad80d192a2066764222fdfed7c553de4df8e876ff8589738dea93d0cf" } diff --git a/backend/.sqlx/query-3020d5477b4822f1b0e3b2e4f2947e24754b919f7ee1aa2e7c1cb8c36e9e94b1.json b/backend/.sqlx/query-3020d5477b4822f1b0e3b2e4f2947e24754b919f7ee1aa2e7c1cb8c36e9e94b1.json new file mode 100644 index 0000000000..71615c6466 --- /dev/null +++ b/backend/.sqlx/query-3020d5477b4822f1b0e3b2e4f2947e24754b919f7ee1aa2e7c1cb8c36e9e94b1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ws_specific (workspace_id, item_kind, path)\n SELECT $1::varchar, 'resource', $2::varchar\n WHERE EXISTS (SELECT 1 FROM resource WHERE workspace_id = $1::varchar AND path = $2::varchar)\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "3020d5477b4822f1b0e3b2e4f2947e24754b919f7ee1aa2e7c1cb8c36e9e94b1" +} diff --git a/backend/.sqlx/query-34b94da001dafbbe66b3b945e71e33128bbc0dcdba9850ccbe4dc278836513bd.json b/backend/.sqlx/query-34b94da001dafbbe66b3b945e71e33128bbc0dcdba9850ccbe4dc278836513bd.json new file mode 100644 index 0000000000..1ca539c46d --- /dev/null +++ b/backend/.sqlx/query-34b94da001dafbbe66b3b945e71e33128bbc0dcdba9850ccbe4dc278836513bd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (parent_workspace_id IS NOT NULL) AS \"is_fork!\" FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_fork!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "34b94da001dafbbe66b3b945e71e33128bbc0dcdba9850ccbe4dc278836513bd" +} diff --git a/backend/.sqlx/query-394e2598880aff8a7f4ee05c3fe748be58b6381f5fae5619d8376daefc3b21db.json b/backend/.sqlx/query-394e2598880aff8a7f4ee05c3fe748be58b6381f5fae5619d8376daefc3b21db.json deleted file mode 100644 index 3470285737..0000000000 --- a/backend/.sqlx/query-394e2598880aff8a7f4ee05c3fe748be58b6381f5fae5619d8376daefc3b21db.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT ON (path) path AS \"path!\", content AS \"content!\"\n FROM script\n WHERE workspace_id = $1\n AND auto_kind = 'pipeline'\n AND archived = false\n AND deleted = false\n AND ($2::text IS NULL OR path LIKE $2)\n ORDER BY path, created_at DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "content!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "394e2598880aff8a7f4ee05c3fe748be58b6381f5fae5619d8376daefc3b21db" -} diff --git a/backend/.sqlx/query-40bcbfdcae9842c7919eb6dcfe44d844508304700b292059b24c3f74454a7cca.json b/backend/.sqlx/query-40bcbfdcae9842c7919eb6dcfe44d844508304700b292059b24c3f74454a7cca.json deleted file mode 100644 index 31c0ab4982..0000000000 --- a/backend/.sqlx/query-40bcbfdcae9842c7919eb6dcfe44d844508304700b292059b24c3f74454a7cca.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "40bcbfdcae9842c7919eb6dcfe44d844508304700b292059b24c3f74454a7cca" -} diff --git a/backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json b/backend/.sqlx/query-41ce08f45b09532cbab6fb039703f08d6476d00d85a02ea3b19aa559b9ec1056.json similarity index 57% rename from backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json rename to backend/.sqlx/query-41ce08f45b09532cbab6fb039703f08d6476d00d85a02ea3b19aa559b9ec1056.json index 4fe298370b..c12e0cdac4 100644 --- a/backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json +++ b/backend/.sqlx/query-41ce08f45b09532cbab6fb039703f08d6476d00d85a02ea3b19aa559b9ec1056.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id)\n VALUES ($1, $2, $3, $4)", + "query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace)\n VALUES ($1, $2, $3, $4, $5)", "describe": { "columns": [], "parameters": { @@ -8,10 +8,11 @@ "Varchar", "Varchar", "Varchar", - "Varchar" + "Varchar", + "Bool" ] }, "nullable": [] }, - "hash": "e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c" + "hash": "41ce08f45b09532cbab6fb039703f08d6476d00d85a02ea3b19aa559b9ec1056" } diff --git a/backend/.sqlx/query-1a9f2ed5045016a3953db335957b26f41efc8a3cad7af7bc8fe97df6a5bf5078.json b/backend/.sqlx/query-42a0ba479ff164cc190c350927e13902ed94816142faf15446b4b9f19c3097d7.json similarity index 76% rename from backend/.sqlx/query-1a9f2ed5045016a3953db335957b26f41efc8a3cad7af7bc8fe97df6a5bf5078.json rename to backend/.sqlx/query-42a0ba479ff164cc190c350927e13902ed94816142faf15446b4b9f19c3097d7.json index 0265d7d2b5..76eab1d117 100644 --- a/backend/.sqlx/query-1a9f2ed5045016a3953db335957b26f41efc8a3cad7af7bc8fe97df6a5bf5078.json +++ b/backend/.sqlx/query-42a0ba479ff164cc190c350927e13902ed94816142faf15446b4b9f19c3097d7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n SELECT $1, email, username, is_admin FROM usr\n WHERE workspace_id = $3 AND email = $2\n ", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n SELECT $1, email, username, is_admin FROM usr\n WHERE workspace_id = $3 AND email = $2\n ON CONFLICT DO NOTHING\n ", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "1a9f2ed5045016a3953db335957b26f41efc8a3cad7af7bc8fe97df6a5bf5078" + "hash": "42a0ba479ff164cc190c350927e13902ed94816142faf15446b4b9f19c3097d7" } diff --git a/backend/.sqlx/query-451d9cde90d14071e21ffb5f615052b7ba7fc315fc301ed5c0ff50d9a3ab0d4a.json b/backend/.sqlx/query-451d9cde90d14071e21ffb5f615052b7ba7fc315fc301ed5c0ff50d9a3ab0d4a.json deleted file mode 100644 index 9e7ffed082..0000000000 --- a/backend/.sqlx/query-451d9cde90d14071e21ffb5f615052b7ba7fc315fc301ed5c0ff50d9a3ab0d4a.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO runnable_settings (hash, debouncing_settings, concurrency_settings)\n VALUES ($1, $2, $3)\n ON CONFLICT (hash)\n DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "451d9cde90d14071e21ffb5f615052b7ba7fc315fc301ed5c0ff50d9a3ab0d4a" -} diff --git a/backend/.sqlx/query-4e4efea2b8d3b0bd2717b27d1895b00e8dba07aa817ee9bfbe2271d41c9b411a.json b/backend/.sqlx/query-4e4efea2b8d3b0bd2717b27d1895b00e8dba07aa817ee9bfbe2271d41c9b411a.json new file mode 100644 index 0000000000..8e9e6a8713 --- /dev/null +++ b/backend/.sqlx/query-4e4efea2b8d3b0bd2717b27d1895b00e8dba07aa817ee9bfbe2271d41c9b411a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET is_dev_workspace = false WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "4e4efea2b8d3b0bd2717b27d1895b00e8dba07aa817ee9bfbe2271d41c9b411a" +} diff --git a/backend/.sqlx/query-5190528997a879981a87420ddf3d28c978c8a5876f5c1ac1613391e86ffb550f.json b/backend/.sqlx/query-5190528997a879981a87420ddf3d28c978c8a5876f5c1ac1613391e86ffb550f.json deleted file mode 100644 index d165e78ad2..0000000000 --- a/backend/.sqlx/query-5190528997a879981a87420ddf3d28c978c8a5876f5c1ac1613391e86ffb550f.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "5190528997a879981a87420ddf3d28c978c8a5876f5c1ac1613391e86ffb550f" -} diff --git a/backend/.sqlx/query-57f375e89d63ac118c5c6767487af401e6d800488ade04facb6af14578c30a89.json b/backend/.sqlx/query-57f375e89d63ac118c5c6767487af401e6d800488ade04facb6af14578c30a89.json deleted file mode 100644 index f634fc2d4b..0000000000 --- a/backend/.sqlx/query-57f375e89d63ac118c5c6767487af401e6d800488ade04facb6af14578c30a89.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH mine AS (\n SELECT debounce_batch, consumed_by FROM v2_job_debounce_batch WHERE id = $1\n ), claim_self AS (\n UPDATE v2_job_debounce_batch SET consumed_at = now(), consumed_by = $1\n WHERE id = $1 AND consumed_at IS NULL\n RETURNING debounce_batch\n ), claim_rest AS (\n UPDATE v2_job_debounce_batch SET consumed_at = now(), consumed_by = $1\n WHERE debounce_batch = (SELECT debounce_batch FROM claim_self)\n AND id <> $1 AND consumed_at IS NULL\n RETURNING id\n )\n SELECT\n EXISTS (SELECT 1 FROM mine) AS \"had_row!\",\n (SELECT debounce_batch FROM claim_self) AS claimed_batch,\n (SELECT consumed_by FROM mine) AS prev_consumed_by,\n ARRAY(SELECT id FROM claim_rest) AS \"claimed_ids!\"\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "had_row!", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "claimed_batch", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "prev_consumed_by", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "claimed_ids!", - "type_info": "UuidArray" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null, - null, - null, - null - ] - }, - "hash": "57f375e89d63ac118c5c6767487af401e6d800488ade04facb6af14578c30a89" -} diff --git a/backend/.sqlx/query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json b/backend/.sqlx/query-63d323be5cacb7a02283d7d82c79bc408b9a33a228b80e88ec3f6432944a7c19.json similarity index 63% rename from backend/.sqlx/query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json rename to backend/.sqlx/query-63d323be5cacb7a02283d7d82c79bc408b9a33a228b80e88ec3f6432944a7c19.json index bb80e8d19a..a9cdc35dce 100644 --- a/backend/.sqlx/query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json +++ b/backend/.sqlx/query-63d323be5cacb7a02283d7d82c79bc408b9a33a228b80e88ec3f6432944a7c19.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", "describe": { "columns": [ { @@ -30,11 +30,16 @@ }, { "ordinal": 5, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 6, "name": "operator_settings", "type_info": "Jsonb" }, { - "ordinal": 6, + "ordinal": 7, "name": "disabled", "type_info": "Bool" } @@ -50,9 +55,10 @@ false, true, true, + false, null, false ] }, - "hash": "c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6" + "hash": "63d323be5cacb7a02283d7d82c79bc408b9a33a228b80e88ec3f6432944a7c19" } diff --git a/backend/.sqlx/query-651fc12e1b971d4fd57c98a7a7efbd503d8dea799545e9cb96574d5c6020b90b.json b/backend/.sqlx/query-651fc12e1b971d4fd57c98a7a7efbd503d8dea799545e9cb96574d5c6020b90b.json deleted file mode 100644 index cb20ec2ffb..0000000000 --- a/backend/.sqlx/query-651fc12e1b971d4fd57c98a7a7efbd503d8dea799545e9cb96574d5c6020b90b.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job\n SET args = CASE\n WHEN args ? 'partition'\n THEN $1 || jsonb_build_object('partition', args -> 'partition')\n ELSE $1\n END,\n preprocessed = TRUE\n WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "651fc12e1b971d4fd57c98a7a7efbd503d8dea799545e9cb96574d5c6020b90b" -} diff --git a/backend/.sqlx/query-70f01b322765442de8888b6d9b79984da751f9ae10c8b15ada925bd371501e18.json b/backend/.sqlx/query-70f01b322765442de8888b6d9b79984da751f9ae10c8b15ada925bd371501e18.json deleted file mode 100644 index e885c72039..0000000000 --- a/backend/.sqlx/query-70f01b322765442de8888b6d9b79984da751f9ae10c8b15ada925bd371501e18.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH del AS (\n DELETE FROM v2_job_debounce_batch\n WHERE consumed_at IS NOT NULL AND consumed_at < now() - interval '1 hour'\n RETURNING 1\n ) SELECT count(*) FROM del", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "70f01b322765442de8888b6d9b79984da751f9ae10c8b15ada925bd371501e18" -} diff --git a/backend/.sqlx/query-754b98335e8776565d63267b395013649adacf348e3a815e991b4463b1711afc.json b/backend/.sqlx/query-754b98335e8776565d63267b395013649adacf348e3a815e991b4463b1711afc.json deleted file mode 100644 index c5266c66b5..0000000000 --- a/backend/.sqlx/query-754b98335e8776565d63267b395013649adacf348e3a815e991b4463b1711afc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT runnable_path AS \"runnable_path!\", kind::text AS \"kind!\"\n FROM v2_job\n WHERE workspace_id = $1 AND trigger_kind = 'asset'\n ORDER BY runnable_path", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "runnable_path!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "kind!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true, - null - ] - }, - "hash": "754b98335e8776565d63267b395013649adacf348e3a815e991b4463b1711afc" -} diff --git a/backend/.sqlx/query-7853a596a01884070455e68bbf8ab2afa79a5b5b8521f68ab19f0470f0265c7c.json b/backend/.sqlx/query-7853a596a01884070455e68bbf8ab2afa79a5b5b8521f68ab19f0470f0265c7c.json new file mode 100644 index 0000000000..f425d1eebd --- /dev/null +++ b/backend/.sqlx/query-7853a596a01884070455e68bbf8ab2afa79a5b5b8521f68ab19f0470f0265c7c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (parent_workspace_id IS NOT NULL) AS \"has_parent!\" FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_parent!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7853a596a01884070455e68bbf8ab2afa79a5b5b8521f68ab19f0470f0265c7c" +} diff --git a/backend/.sqlx/query-78bb75578a880715fb482445883e0e762f289b7695f29bfd44fa23323c7e8523.json b/backend/.sqlx/query-78bb75578a880715fb482445883e0e762f289b7695f29bfd44fa23323c7e8523.json deleted file mode 100644 index f7d3361d99..0000000000 --- a/backend/.sqlx/query-78bb75578a880715fb482445883e0e762f289b7695f29bfd44fa23323c7e8523.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "78bb75578a880715fb482445883e0e762f289b7695f29bfd44fa23323c7e8523" -} diff --git a/backend/.sqlx/query-7b950d49cb1cc7f9bf8032c9e7655c49027337a9eca1f1ded13dbae6475e3376.json b/backend/.sqlx/query-7b950d49cb1cc7f9bf8032c9e7655c49027337a9eca1f1ded13dbae6475e3376.json new file mode 100644 index 0000000000..e1cf9bc17f --- /dev/null +++ b/backend/.sqlx/query-7b950d49cb1cc7f9bf8032c9e7655c49027337a9eca1f1ded13dbae6475e3376.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "7b950d49cb1cc7f9bf8032c9e7655c49027337a9eca1f1ded13dbae6475e3376" +} diff --git a/backend/.sqlx/query-8192986cd6106ed060b3d68dbc21e5bb34f5e68a5c6ac455a9d423188af77b23.json b/backend/.sqlx/query-8192986cd6106ed060b3d68dbc21e5bb34f5e68a5c6ac455a9d423188af77b23.json deleted file mode 100644 index 7a0c5578b0..0000000000 --- a/backend/.sqlx/query-8192986cd6106ed060b3d68dbc21e5bb34f5e68a5c6ac455a9d423188af77b23.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO background_task_state (name, value)\n SELECT $1, jsonb_build_object(\n 'last_xmin', txid_snapshot_xmin(txid_current_snapshot())::bigint,\n 'last_ts', '1970-01-01T00:00:00+00:00')\n WHERE NOT EXISTS (SELECT 1 FROM global_settings WHERE name = $2)\n ON CONFLICT (name) DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "8192986cd6106ed060b3d68dbc21e5bb34f5e68a5c6ac455a9d423188af77b23" -} diff --git a/backend/.sqlx/query-82185eb02e03e3dd1a4b5a3f22c3b60169989703ee33d14ab348301885c9d745.json b/backend/.sqlx/query-82185eb02e03e3dd1a4b5a3f22c3b60169989703ee33d14ab348301885c9d745.json deleted file mode 100644 index c0573a8fab..0000000000 --- a/backend/.sqlx/query-82185eb02e03e3dd1a4b5a3f22c3b60169989703ee33d14ab348301885c9d745.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace SELECT $1, $2, owner, false, premium FROM workspace WHERE id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "82185eb02e03e3dd1a4b5a3f22c3b60169989703ee33d14ab348301885c9d745" -} diff --git a/backend/.sqlx/query-8288b3916022adcbb28da2a0e4329cba84759b8a590546aea8f0ddab4f861c38.json b/backend/.sqlx/query-8288b3916022adcbb28da2a0e4329cba84759b8a590546aea8f0ddab4f861c38.json new file mode 100644 index 0000000000..673becc2ff --- /dev/null +++ b/backend/.sqlx/query-8288b3916022adcbb28da2a0e4329cba84759b8a590546aea8f0ddab4f861c38.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "8288b3916022adcbb28da2a0e4329cba84759b8a590546aea8f0ddab4f861c38" +} diff --git a/backend/.sqlx/query-82dcaf94ffe43da1c8c7de2a3478b4919c4f1dbf1972d04664a730cefc0594e2.json b/backend/.sqlx/query-82dcaf94ffe43da1c8c7de2a3478b4919c4f1dbf1972d04664a730cefc0594e2.json deleted file mode 100644 index 96587ed1d0..0000000000 --- a/backend/.sqlx/query-82dcaf94ffe43da1c8c7de2a3478b4919c4f1dbf1972d04664a730cefc0594e2.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT q.runnable_settings_handle\n FROM v2_job j JOIN v2_job_queue q ON q.id = j.id\n WHERE j.workspace_id = $1 AND j.runnable_path = $2\n AND j.trigger_kind = 'asset'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "runnable_settings_handle", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "82dcaf94ffe43da1c8c7de2a3478b4919c4f1dbf1972d04664a730cefc0594e2" -} diff --git a/backend/.sqlx/query-84cbf9623a989dc16be1f8681c95eed9ae3e8d9c1552f396b6773767087bcfec.json b/backend/.sqlx/query-84cbf9623a989dc16be1f8681c95eed9ae3e8d9c1552f396b6773767087bcfec.json new file mode 100644 index 0000000000..0859d3e071 --- /dev/null +++ b/backend/.sqlx/query-84cbf9623a989dc16be1f8681c95eed9ae3e8d9c1552f396b6773767087bcfec.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "84cbf9623a989dc16be1f8681c95eed9ae3e8d9c1552f396b6773767087bcfec" +} diff --git a/backend/.sqlx/query-90d21ee2276b41a4dd3dd8ed12f36e00b8d4fc27a2d72b74dff2dace900db75c.json b/backend/.sqlx/query-90d21ee2276b41a4dd3dd8ed12f36e00b8d4fc27a2d72b74dff2dace900db75c.json new file mode 100644 index 0000000000..3f159707b6 --- /dev/null +++ b/backend/.sqlx/query-90d21ee2276b41a4dd3dd8ed12f36e00b8d4fc27a2d72b74dff2dace900db75c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ws_specific (workspace_id, item_kind, path)\n SELECT $1::varchar, 'variable', $2::varchar\n WHERE EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1::varchar AND path = $2::varchar)\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "90d21ee2276b41a4dd3dd8ed12f36e00b8d4fc27a2d72b74dff2dace900db75c" +} diff --git a/backend/.sqlx/query-910b9b8afb3df5e437e43ff4adc97d0a03f6c26f0b14fc92900f7600e70a7a8b.json b/backend/.sqlx/query-910b9b8afb3df5e437e43ff4adc97d0a03f6c26f0b14fc92900f7600e70a7a8b.json deleted file mode 100644 index 569a5122ba..0000000000 --- a/backend/.sqlx/query-910b9b8afb3df5e437e43ff4adc97d0a03f6c26f0b14fc92900f7600e70a7a8b.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT status = 'success' AS \"success!\"\n FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.id != $4\n ORDER BY created_at DESC\n LIMIT $5", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "success!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Uuid", - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "910b9b8afb3df5e437e43ff4adc97d0a03f6c26f0b14fc92900f7600e70a7a8b" -} diff --git a/backend/.sqlx/query-a22611ed3b7a6caa76a218454494c96e1371956bb303eb8d3e3268139cd8fce0.json b/backend/.sqlx/query-a22611ed3b7a6caa76a218454494c96e1371956bb303eb8d3e3268139cd8fce0.json new file mode 100644 index 0000000000..2b9b0619a1 --- /dev/null +++ b/backend/.sqlx/query-a22611ed3b7a6caa76a218454494c96e1371956bb303eb8d3e3268139cd8fce0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a22611ed3b7a6caa76a218454494c96e1371956bb303eb8d3e3268139cd8fce0" +} diff --git a/backend/.sqlx/query-a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d.json b/backend/.sqlx/query-a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d.json deleted file mode 100644 index 819928ddc1..0000000000 --- a/backend/.sqlx/query-a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END\n FROM workspace WHERE id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d" -} diff --git a/backend/.sqlx/query-a981f9b6424969e5fd72fb18c20e2910a138a848f3f6674dec58053572682077.json b/backend/.sqlx/query-a981f9b6424969e5fd72fb18c20e2910a138a848f3f6674dec58053572682077.json new file mode 100644 index 0000000000..5d20f83f9d --- /dev/null +++ b/backend/.sqlx/query-a981f9b6424969e5fd72fb18c20e2910a138a848f3f6674dec58053572682077.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a981f9b6424969e5fd72fb18c20e2910a138a848f3f6674dec58053572682077" +} diff --git a/backend/.sqlx/query-a999a5cf7b481d852222311a34959f38114926063718668907587d1b80dfc75e.json b/backend/.sqlx/query-a999a5cf7b481d852222311a34959f38114926063718668907587d1b80dfc75e.json new file mode 100644 index 0000000000..b022524bbb --- /dev/null +++ b/backend/.sqlx/query-a999a5cf7b481d852222311a34959f38114926063718668907587d1b80dfc75e.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Jsonb", + "Text", + "Varchar", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "a999a5cf7b481d852222311a34959f38114926063718668907587d1b80dfc75e" +} diff --git a/backend/.sqlx/query-a9a99880d870266f474878dd6ef541df988da527d30f663ef6f764f0c3d70d4b.json b/backend/.sqlx/query-a9a99880d870266f474878dd6ef541df988da527d30f663ef6f764f0c3d70d4b.json deleted file mode 100644 index d12d305b92..0000000000 --- a/backend/.sqlx/query-a9a99880d870266f474878dd6ef541df988da527d30f663ef6f764f0c3d70d4b.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT path AS \"path!\"\n FROM script\n WHERE workspace_id = $1\n AND auto_kind = 'pipeline'\n AND archived = false\n AND deleted = false\n AND ($2::text IS NULL OR path LIKE $2)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "a9a99880d870266f474878dd6ef541df988da527d30f663ef6f764f0c3d70d4b" -} diff --git a/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json b/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json new file mode 100644 index 0000000000..94cf77ebc5 --- /dev/null +++ b/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142" +} diff --git a/backend/.sqlx/query-bf6e1abbab6bdf0e67eefa5f807a26b06d24a7b3e292ae4be97fbbb197d468db.json b/backend/.sqlx/query-bf6e1abbab6bdf0e67eefa5f807a26b06d24a7b3e292ae4be97fbbb197d468db.json new file mode 100644 index 0000000000..35d3ace40e --- /dev/null +++ b/backend/.sqlx/query-bf6e1abbab6bdf0e67eefa5f807a26b06d24a7b3e292ae4be97fbbb197d468db.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_diff SET has_changes = NULL\n WHERE path = $2 AND kind = $3\n AND ($1 IN (source_workspace_id, fork_workspace_id))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bf6e1abbab6bdf0e67eefa5f807a26b06d24a7b3e292ae4be97fbbb197d468db" +} diff --git a/backend/.sqlx/query-bfa4fb5998dec9baf89a2d950186e2dc9c0aecf7831ce66082a40f17cc5ac0de.json b/backend/.sqlx/query-bfa4fb5998dec9baf89a2d950186e2dc9c0aecf7831ce66082a40f17cc5ac0de.json new file mode 100644 index 0000000000..a1f7783645 --- /dev/null +++ b/backend/.sqlx/query-bfa4fb5998dec9baf89a2d950186e2dc9c0aecf7831ce66082a40f17cc5ac0de.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(\n SELECT 1 FROM workspace\n WHERE id = $1 AND parent_workspace_id = $2 AND is_dev_workspace\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bfa4fb5998dec9baf89a2d950186e2dc9c0aecf7831ce66082a40f17cc5ac0de" +} diff --git a/backend/.sqlx/query-c886e8af0fc8a3999a813371855c0053571e79960280f0714616d13a456d7bed.json b/backend/.sqlx/query-c886e8af0fc8a3999a813371855c0053571e79960280f0714616d13a456d7bed.json deleted file mode 100644 index 7361b645b5..0000000000 --- a/backend/.sqlx/query-c886e8af0fc8a3999a813371855c0053571e79960280f0714616d13a456d7bed.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO v2_job_debounce_batch (id, debounce_batch, consumed_at) VALUES\n ($1, nextval('debounce_batch_seq'), now() - interval '20 minutes'),\n ($2, nextval('debounce_batch_seq'), now() - interval '1 minute'),\n ($3, nextval('debounce_batch_seq'), NULL)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "c886e8af0fc8a3999a813371855c0053571e79960280f0714616d13a456d7bed" -} diff --git a/backend/.sqlx/query-d0e826043e5a129ae6768c274c67b6254ff6c5fd450ecdab886a3183a894d266.json b/backend/.sqlx/query-d0e826043e5a129ae6768c274c67b6254ff6c5fd450ecdab886a3183a894d266.json deleted file mode 100644 index e900b0f9e0..0000000000 --- a/backend/.sqlx/query-d0e826043e5a129ae6768c274c67b6254ff6c5fd450ecdab886a3183a894d266.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH ids AS (\n SELECT id as job_id FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )\n ) SELECT args->>$2 FROM ids LEFT JOIN v2_job ON v2_job.id = ids.job_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "d0e826043e5a129ae6768c274c67b6254ff6c5fd450ecdab886a3183a894d266" -} diff --git a/backend/.sqlx/query-d3ce4e7f3dd10548197734a0cf38c644f70be809dcfe7d973147962530c601d5.json b/backend/.sqlx/query-d3ce4e7f3dd10548197734a0cf38c644f70be809dcfe7d973147962530c601d5.json new file mode 100644 index 0000000000..9f8b86b4e6 --- /dev/null +++ b/backend/.sqlx/query-d3ce4e7f3dd10548197734a0cf38c644f70be809dcfe7d973147962530c601d5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2\n AND NOT EXISTS (\n SELECT 1 FROM workspace\n WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d3ce4e7f3dd10548197734a0cf38c644f70be809dcfe7d973147962530c601d5" +} diff --git a/backend/.sqlx/query-d74dfaa8cb9fca89c2e21f810cd45ff7b595c7c50f5e6ff2733eded6ef544996.json b/backend/.sqlx/query-d74dfaa8cb9fca89c2e21f810cd45ff7b595c7c50f5e6ff2733eded6ef544996.json new file mode 100644 index 0000000000..4a511c2d6e --- /dev/null +++ b/backend/.sqlx/query-d74dfaa8cb9fca89c2e21f810cd45ff7b595c7c50f5e6ff2733eded6ef544996.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d74dfaa8cb9fca89c2e21f810cd45ff7b595c7c50f5e6ff2733eded6ef544996" +} diff --git a/backend/.sqlx/query-d8f234765e2de89f780937a505c236c5be9b36b4df95735fedb44ed944606ac8.json b/backend/.sqlx/query-d8f234765e2de89f780937a505c236c5be9b36b4df95735fedb44ed944606ac8.json new file mode 100644 index 0000000000..48a5c693ac --- /dev/null +++ b/backend/.sqlx/query-d8f234765e2de89f780937a505c236c5be9b36b4df95735fedb44ed944606ac8.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2 RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d8f234765e2de89f780937a505c236c5be9b36b4df95735fedb44ed944606ac8" +} diff --git a/backend/.sqlx/query-de06f44bad94710f14e9be4c0a6e6080e3c4faae5052500b93cc24b6fe556f2b.json b/backend/.sqlx/query-de06f44bad94710f14e9be4c0a6e6080e3c4faae5052500b93cc24b6fe556f2b.json deleted file mode 100644 index 2ba317edc6..0000000000 --- a/backend/.sqlx/query-de06f44bad94710f14e9be4c0a6e6080e3c4faae5052500b93cc24b6fe556f2b.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n kind AS \"kind!: AssetKind\",\n path AS \"path!\"\n FROM asset\n WHERE workspace_id = $1\n AND usage_kind = 'script'\n AND usage_path = $2\n AND usage_access_type IN ('w', 'rw')\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "kind!: AssetKind", - "type_info": { - "Custom": { - "name": "asset_kind", - "kind": { - "Enum": [ - "s3object", - "resource", - "variable", - "ducklake", - "datatable", - "volume" - ] - } - } - } - }, - { - "ordinal": 1, - "name": "path!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "de06f44bad94710f14e9be4c0a6e6080e3c4faae5052500b93cc24b6fe556f2b" -} diff --git a/backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json b/backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json deleted file mode 100644 index 67ce9d8719..0000000000 --- a/backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Jsonb", - "Text", - "Varchar", - "Varchar", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef" -} diff --git a/backend/.sqlx/query-e6a8ddfd74ebab55ede5989fd7d09b246b6f84e49c659bc8e2c7b66cfec6d976.json b/backend/.sqlx/query-e6a8ddfd74ebab55ede5989fd7d09b246b6f84e49c659bc8e2c7b66cfec6d976.json deleted file mode 100644 index a1b52e81fd..0000000000 --- a/backend/.sqlx/query-e6a8ddfd74ebab55ede5989fd7d09b246b6f84e49c659bc8e2c7b66cfec6d976.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT status = 'success' AS \"success!\",\n result AS \"result: Json>\",\n started_at AS \"started_at!\"FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.id != $4\n ORDER BY created_at DESC\n LIMIT $5", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "success!", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "result: Json>", - "type_info": "Jsonb" - }, - { - "ordinal": 2, - "name": "started_at!", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Uuid", - "Int8" - ] - }, - "nullable": [ - null, - true, - true - ] - }, - "hash": "e6a8ddfd74ebab55ede5989fd7d09b246b6f84e49c659bc8e2c7b66cfec6d976" -} diff --git a/backend/.sqlx/query-ebbbd069e0f33be9609604025d159fe1ecbefc2e9c11f7c4900b7121d4367e01.json b/backend/.sqlx/query-ebbbd069e0f33be9609604025d159fe1ecbefc2e9c11f7c4900b7121d4367e01.json deleted file mode 100644 index 146c7f05b3..0000000000 --- a/backend/.sqlx/query-ebbbd069e0f33be9609604025d159fe1ecbefc2e9c11f7c4900b7121d4367e01.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT concurrency_settings, debouncing_settings FROM runnable_settings WHERE hash = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "concurrency_settings", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "debouncing_settings", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - true, - true - ] - }, - "hash": "ebbbd069e0f33be9609604025d159fe1ecbefc2e9c11f7c4900b7121d4367e01" -} diff --git a/backend/.sqlx/query-f1f79711f131ff4116489153db1b71e528c66fab1508e5c41b63bc5b9077a08c.json b/backend/.sqlx/query-f1f79711f131ff4116489153db1b71e528c66fab1508e5c41b63bc5b9077a08c.json new file mode 100644 index 0000000000..844dfb3e47 --- /dev/null +++ b/backend/.sqlx/query-f1f79711f131ff4116489153db1b71e528c66fab1508e5c41b63bc5b9077a08c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Jsonb", + "Text", + "Varchar", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "f1f79711f131ff4116489153db1b71e528c66fab1508e5c41b63bc5b9077a08c" +} diff --git a/backend/.sqlx/query-fa04b3660f1f90c39c3d12f39c51df8f25a3bdae60aeaed313612cb858040c8d.json b/backend/.sqlx/query-fa04b3660f1f90c39c3d12f39c51df8f25a3bdae60aeaed313612cb858040c8d.json new file mode 100644 index 0000000000..7f967b60a5 --- /dev/null +++ b/backend/.sqlx/query-fa04b3660f1f90c39c3d12f39c51df8f25a3bdae60aeaed313612cb858040c8d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = $2 AND path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "fa04b3660f1f90c39c3d12f39c51df8f25a3bdae60aeaed313612cb858040c8d" +} diff --git a/backend/migrations/20260624161218_dev_workspace.down.sql b/backend/migrations/20260624161218_dev_workspace.down.sql new file mode 100644 index 0000000000..45b8e65f67 --- /dev/null +++ b/backend/migrations/20260624161218_dev_workspace.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE workspace DROP CONSTRAINT IF EXISTS workspace_dev_requires_parent; +DROP INDEX IF EXISTS workspace_canonical_dev_idx; +ALTER TABLE workspace DROP COLUMN is_dev_workspace; diff --git a/backend/migrations/20260624161218_dev_workspace.up.sql b/backend/migrations/20260624161218_dev_workspace.up.sql new file mode 100644 index 0000000000..a0a252eb7c --- /dev/null +++ b/backend/migrations/20260624161218_dev_workspace.up.sql @@ -0,0 +1,14 @@ +-- A dev workspace is a fork (parent_workspace_id set) that is the standing editable +-- environment paired with its parent ("prod"), as opposed to a throwaway fork. +ALTER TABLE workspace ADD COLUMN is_dev_workspace BOOLEAN NOT NULL DEFAULT false; + +-- At most one active canonical dev workspace per parent (one editable source per prod). +-- Excludes soft-deleted (archived) workspaces so a new dev can replace an archived one. +CREATE UNIQUE INDEX workspace_canonical_dev_idx ON workspace (parent_workspace_id) + WHERE is_dev_workspace AND deleted = false; + +-- A dev workspace is a fork, so it must have a parent. Enforce the invariant at the schema level so +-- no path (or manual write) can persist a "root dev workspace". No backfill needed: the column is +-- added above with default false, so no existing row can violate this at creation time. +ALTER TABLE workspace ADD CONSTRAINT workspace_dev_requires_parent + CHECK (NOT is_dev_workspace OR parent_workspace_id IS NOT NULL); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 6553abb5cc..fe86b09b09 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -182,7 +182,7 @@ windmill_migrations: name(text), created_at(ts) worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint) FK: (workspace_id) -> workspace(id) worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]) -workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char) +workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool) FK: (parent_workspace_id) -> workspace(id) workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts) workspace_diff: source_workspace_id(char), fork_workspace_id(char), path(char), kind(char), ahead(int), behind(int), has_changes(bool), exists_in_source(bool), exists_in_fork(bool) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 5bdf0acf1a..589fed899f 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6,7 +6,10 @@ * LICENSE-AGPL for a copy of the license. */ -use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed}; +use windmill_api_auth::{ + build_scope_path_predicate, check_scopes, require_devops_role, require_is_writer, + require_super_admin, ApiAuthed, +}; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; @@ -40,9 +43,10 @@ use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ - check_user_against_rule, get_datatable_resource_from_db_unchecked, validate_fork_workspace_id, - DataTable, DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, - ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, + check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked, + validate_dev_workspace_id, validate_fork_workspace_id, DataTable, DataTableCatalogResourceType, + DataTableForkBehavior, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, + WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; @@ -150,6 +154,9 @@ pub fn workspaced_service() -> Router { .route("/leave", post(leave_workspace)) .route("/get_workspace_name", get(get_workspace_name)) .route("/create_fork", post(create_workspace_fork)) + .route("/attach_dev_workspace", post(attach_dev_workspace)) + .route("/detach_dev_workspace", post(detach_dev_workspace)) + .route("/get_dev_workspace", get(get_dev_workspace)) .route("/change_workspace_name", post(change_workspace_name)) .route("/change_workspace_color", post(change_workspace_color)) .route( @@ -191,6 +198,7 @@ pub fn workspaced_service() -> Router { .route("/prune_versions", post(prune_versions)) .route("/list_ws_specific", get(list_ws_specific)) .route("/list_ws_specific_versions", get(list_ws_specific_versions)) + .route("/set_ws_specific", post(set_ws_specific)) } pub fn global_service() -> Router { Router::new() @@ -441,6 +449,20 @@ struct CreateWorkspaceFork { /// forked workspace's datatable config to point to the new database. #[serde(default)] forked_datatables: Vec, + /// Create the fork as a persistent dev workspace: the id is not required to carry the + /// `wm-fork-` prefix, and at most one dev workspace may exist per parent. + #[serde(default)] + is_dev_workspace: bool, + /// When creating a dev workspace, lock the parent ("prod") against direct deployment and/or + /// ad-hoc forking, so edits are funneled through the dev workspace. + #[serde(default)] + lock_prod_deploy: bool, + #[serde(default)] + lock_prod_forking: bool, + /// Copy the parent's members (usr rows + group memberships) into the fork so + /// the team can work in it. Defaults off; the dev-workspace UI defaults it on. + #[serde(default)] + copy_members: bool, } #[derive(Deserialize)] @@ -469,6 +491,7 @@ struct UserWorkspace { pub color: Option, pub operator_settings: Option>, pub parent_workspace_id: Option, + pub is_dev_workspace: bool, pub disabled: bool, } @@ -637,6 +660,34 @@ async fn exists_workspace( Ok(Json(exists)) } +/// Whether this workspace already has an active canonical dev workspace. The create-fork UI can't +/// rely on the caller's workspace list to decide this — a dev paired to this prod may exist that the +/// caller isn't a member of — so it asks the server, which sees all children. +#[derive(Serialize)] +struct DevWorkspaceInfo { + id: String, + name: String, +} + +/// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace +/// settings tab can't rely on the caller's workspace list — a dev paired to this prod may exist that +/// the caller isn't a member of — so they ask the server, which sees all children. Returns its id/name +/// so a prod admin who isn't a dev member can still see the pairing and detach it. +async fn get_dev_workspace( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let dev = sqlx::query_as!( + DevWorkspaceInfo, + "SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + &w_id + ) + .fetch_optional(&db) + .await?; + Ok(Json(dev)) +} + async fn list_workspaces( authed: ApiAuthed, Extension(user_db): Extension, @@ -3610,6 +3661,7 @@ async fn user_workspaces( let workspaces = sqlx::query_as!( UserWorkspace, "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, + workspace.is_dev_workspace, CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, usr.disabled FROM workspace @@ -4378,6 +4430,28 @@ async fn clone_groups( Ok(()) } +/// Copy the source workspace's members (the `usr` rows, carrying each member's role) into the +/// target so a fork/dev can be a shared environment. Idempotent — skips members the target already +/// has. Group memberships are not handled here: the sole caller is the create-fork path, where +/// `clone_groups` already copies the source's full group structure (including `all` membership). +async fn copy_workspace_members( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via) + SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via + FROM usr WHERE workspace_id = $2 + ON CONFLICT DO NOTHING", + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + async fn clone_resource_types( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -4927,7 +5001,26 @@ async fn create_workspace_fork_branch( return Err(Error::PermissionDenied(msg)); } - validate_fork_workspace_id(&nw.id)?; + // Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and + // validates up front; it does NOT create the workspace row. The caller follows up with + // `create_workspace_fork`, which inserts the row and applies the dev designation + prod lock + + // member copy. So the dev/lock/copy_members fields here are validated only — they are acted on by + // that second call. Validating early lets a bad request fail before any branch is created. + if nw.is_dev_workspace { + validate_dev_workspace_id(&nw.id)?; + ensure_dev_parent_is_root(&db, &w_id).await?; + // Reject before creating any git branch if the parent already has a dev workspace, + // otherwise the deferred branch-creation job leaves a dangling branch on the synced repos. + ensure_no_existing_dev_workspace(&db, &w_id).await?; + // Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking the + // parent mutates its protection rules), so require admin of the parent regardless of the lock + // flags — mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker + // could claim the dev slot. Enforced in this first phase too so the request fails before any + // git branch is created rather than leaving dangling branches. + require_admin(authed.is_admin, &authed.username)?; + } else { + validate_fork_workspace_id(&nw.id)?; + } // Fail before creating any git branch so a name conflict doesn't leave a // dangling branch on the synced repos. @@ -5072,7 +5165,11 @@ async fn create_workspace_fork( ))); } - validate_fork_workspace_id(&nw.id)?; + if nw.is_dev_workspace { + validate_dev_workspace_id(&nw.id)?; + } else { + validate_fork_workspace_id(&nw.id)?; + } // Check the id conflict before the CE workspace-count limit so that // re-using a taken (possibly archived) fork id reports the actual // conflict instead of a misleading "maximum number of workspaces" error. @@ -5098,18 +5195,29 @@ async fn create_workspace_fork( return Err(Error::PermissionDenied(msg)); } + if nw.is_dev_workspace { + ensure_dev_parent_is_root(&db, &parent_workspace_id).await?; + // Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod + // mutates its protection rules), so require admin of the parent regardless of the lock flags — + // mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could + // claim the dev slot (and, without member copy, prod admins might not even see it to detach). + require_admin(authed.is_admin, &authed.username)?; + ensure_no_existing_dev_workspace(&db, &parent_workspace_id).await?; + } + let mut tx: Transaction<'_, Postgres> = db.begin().await?; let forked_id = nw.id; sqlx::query!( "INSERT INTO workspace - (id, name, owner, parent_workspace_id) - VALUES ($1, $2, $3, $4)", + (id, name, owner, parent_workspace_id, is_dev_workspace) + VALUES ($1, $2, $3, $4, $5)", forked_id, nw.name, authed.email, parent_workspace_id, + nw.is_dev_workspace, ) .execute(&mut *tx) .await?; @@ -5124,11 +5232,24 @@ async fn create_workspace_fork( .execute(&mut *tx) .await?; + // Optionally bring the parent's members into the fork (a shared dev env). Dev-only: it's part of the + // dev-workspace feature (and the frontend only offers it there), so the backend enforces it rather + // than trusting the client — copying the parent's whole team into an ordinary throwaway fork isn't + // intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before + // the explicit creator insert below so the creator (a parent member) is copied with full metadata + // (operator/role/is_service_account/added_via), not the bare row the insert alone would leave. + if nw.copy_members && nw.is_dev_workspace { + copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?; + } + + // Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent + // member). No-op when copy_members already brought their full row. sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin) SELECT $1, email, username, is_admin FROM usr WHERE workspace_id = $3 AND email = $2 + ON CONFLICT DO NOTHING ", forked_id, authed.email, @@ -5151,6 +5272,18 @@ async fn create_workspace_fork( apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?; } + // Lock the parent ("prod") so edits are funneled through this dev workspace. + let locked_prod = nw.is_dev_workspace && (nw.lock_prod_deploy || nw.lock_prod_forking); + if locked_prod { + lock_prod_workspace( + &mut tx, + &parent_workspace_id, + nw.lock_prod_deploy, + nw.lock_prod_forking, + ) + .await?; + } + audit_log( &mut *tx, &authed, @@ -5163,9 +5296,235 @@ async fn create_workspace_fork( .await?; tx.commit().await?; + if locked_prod { + windmill_common::workspaces::invalidate_protection_rules_cache(&parent_workspace_id); + } + Ok(format!("Created forked workspace {}", &forked_id)) } +#[derive(Deserialize)] +struct AttachDevWorkspace { + dev_workspace_id: String, + #[serde(default)] + lock_prod_deploy: bool, + #[serde(default)] + lock_prod_forking: bool, +} + +#[derive(Deserialize)] +struct DetachDevWorkspace { + dev_workspace_id: String, +} + +/// Pair an existing standalone workspace to this workspace ("prod") as its dev workspace, without +/// cloning any data (both already exist). Sets the dev's parent + deploy_to to prod and, optionally, +/// locks prod against direct deployment. +async fn attach_dev_workspace( + authed: ApiAuthed, + Extension(db): Extension, + Path(prod_w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + if *CLOUD_HOSTED { + return Err(Error::BadRequest( + "Dev workspaces are not available on app.windmill.dev".to_string(), + )); + } + + let dev_w_id = req.dev_workspace_id; + if dev_w_id == prod_w_id { + return Err(Error::BadRequest( + "A workspace cannot be its own dev workspace".to_string(), + )); + } + + // The id is interpolated into a `wm-fork//` branch name like any fork. + validate_dev_workspace_id(&dev_w_id)?; + + let dev = sqlx::query!( + r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, + &dev_w_id + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?; + + if dev.deleted { + return Err(Error::BadRequest(format!( + "Workspace {} is archived", + dev_w_id + ))); + } + // A candidate that already belongs to a DIFFERENT parent can't be attached. A candidate already + // parented to this prod is allowed: it's the recovery path after renaming a dev workspace (the + // rename keeps the parent but drops the dev flag), and re-designating an existing fork of this + // prod as its dev. + if dev + .parent_workspace_id + .as_deref() + .is_some_and(|p| p != prod_w_id) + { + return Err(Error::BadRequest(format!( + "Workspace {} is already a fork or dev workspace of another workspace", + dev_w_id + ))); + } + // The candidate can't itself be a prod with its own dev workspace (no nested dev chains). + ensure_no_existing_dev_workspace(&db, &dev_w_id).await?; + + // Prod must be a root workspace, otherwise attaching could form a parent<->child cycle (e.g. + // attaching A as the dev of B when B is already the dev of A), which breaks hierarchy traversal. + let prod_has_parent = sqlx::query_scalar!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#, + &prod_w_id + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", prod_w_id)))?; + if prod_has_parent { + return Err(Error::BadRequest(format!( + "Workspace {} is itself a fork or dev workspace and cannot be a prod workspace", + prod_w_id + ))); + } + + // The caller must be admin of the dev workspace too (or a superadmin). + let is_admin_of_dev = sqlx::query_scalar!( + "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", + &dev_w_id, + &authed.email + ) + .fetch_optional(&db) + .await? + .unwrap_or(false); + if !is_admin_of_dev && !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? { + return Err(Error::PermissionDenied(format!( + "Attaching workspace '{dev_w_id}' as a dev requires being an admin of it (or a superadmin)" + ))); + } + + ensure_no_existing_dev_workspace(&db, &prod_w_id).await?; + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2", + &prod_w_id, + &dev_w_id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2", + &prod_w_id, + &dev_w_id + ) + .execute(&mut *tx) + .await?; + + if req.lock_prod_deploy || req.lock_prod_forking { + lock_prod_workspace( + &mut tx, + &prod_w_id, + req.lock_prod_deploy, + req.lock_prod_forking, + ) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "workspaces.attach_dev_workspace", + ActionKind::Update, + &prod_w_id, + Some(&dev_w_id), + None, + ) + .await?; + tx.commit().await?; + + // The dev workspace's parent just changed (none -> prod); drop its cached fork->parent mapping + // so per-workspace job tags route to the prod family immediately rather than after the TTL. + windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id); + + if req.lock_prod_deploy || req.lock_prod_forking { + windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id); + } + + Ok(format!( + "Attached {} as dev workspace of {}", + dev_w_id, prod_w_id + )) +} + +/// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the +/// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork). +async fn detach_dev_workspace( + authed: ApiAuthed, + Extension(db): Extension, + Path(prod_w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let dev_w_id = req.dev_workspace_id; + let is_dev_of_prod = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM workspace + WHERE id = $1 AND parent_workspace_id = $2 AND is_dev_workspace + )"#, + &dev_w_id, + &prod_w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !is_dev_of_prod { + return Err(Error::BadRequest(format!( + "{} is not the dev workspace of {}", + dev_w_id, prod_w_id + ))); + } + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace SET is_dev_workspace = false WHERE id = $1", + &dev_w_id + ) + .execute(&mut *tx) + .await?; + // Only one dev per prod, so detaching it means prod no longer has a dev: drop the lock rule. + sqlx::query!( + "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2", + &prod_w_id, + DEV_WORKSPACE_LOCK_RULE_NAME + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.detach_dev_workspace", + ActionKind::Update, + &prod_w_id, + Some(&dev_w_id), + None, + ) + .await?; + tx.commit().await?; + + windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id); + + Ok(format!( + "Detached dev workspace {} from {}", + dev_w_id, prod_w_id + )) +} + async fn edit_workspace( authed: ApiAuthed, Extension(db): Extension, @@ -5205,6 +5564,10 @@ pub(crate) async fn archive_workspace_impl( db: &DB, w_id: &str, username: &str, + // When archiving a dev workspace, its parent prod. The pairing teardown (clear is_dev + drop the + // prod's lock) is folded into the same transaction as `deleted = true` so it's atomic with the + // archive — a later failure can't strand a half-archived dev that's still flagged/locked. + dev_lock_parent: Option<&str>, ) -> Result<(usize, usize, usize)> { // Step 1: Disable all schedules and clear their queued jobs let mut tx = db.begin().await?; @@ -5246,6 +5609,30 @@ pub(crate) async fn archive_workspace_impl( .execute(&mut *tx) .await?; + if let Some(prod) = dev_lock_parent { + // Dissolve the dev pairing atomically with the archive: clear the canonical-dev flag (so the + // archived row no longer occupies the parent's one-dev slot), and drop the prod's lock unless a + // replacement dev already holds it (NOT EXISTS sees the just-cleared flag within this tx, so the + // row being archived doesn't count). + sqlx::query!( + "UPDATE workspace SET is_dev_workspace = false WHERE id = $1", + w_id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2 + AND NOT EXISTS ( + SELECT 1 FROM workspace + WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false + )", + prod, + DEV_WORKSPACE_LOCK_RULE_NAME + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; // Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs) @@ -5289,8 +5676,39 @@ async fn archive_workspace( ) -> Result { require_admin(authed.is_admin, &authed.username)?; + // If this is an attached dev workspace, archiving it leaves the prod with no active dev (the + // unique index and user_workspaces both ignore deleted=true), so clear the prod's + // dev_workspace_lock too. Gate it on prod-admin since it removes prod's protection rule (mirrors + // detach/delete) — a dev-admin who isn't a prod-admin must not be able to unlock prod this way. + let dev_lock_parent: Option = sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(); + if let Some(ref prod) = dev_lock_parent { + let is_prod_admin = sqlx::query_scalar!( + "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", + prod, + &authed.email + ) + .fetch_optional(&db) + .await? + .unwrap_or(false); + if !is_prod_admin + && !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? + { + return Err(Error::PermissionDenied(format!( + "Archiving dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)" + ))); + } + } + + // The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's + // transaction, atomically with `deleted = true`. let (schedules_count, canceled_count, deleted_tokens_count) = - archive_workspace_impl(&db, &w_id, &authed.username).await?; + archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?; // Audit log let mut tx = db.begin().await?; @@ -5325,6 +5743,10 @@ async fn archive_workspace( .await?; tx.commit().await?; + if let Some(prod) = dev_lock_parent { + windmill_common::workspaces::invalidate_protection_rules_cache(&prod); + } + Ok(format!( "Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens", &w_id, schedules_count, canceled_count, deleted_tokens_count @@ -6166,6 +6588,119 @@ async fn list_protection_rules( )) } +/// Insert or replace a protection ruleset within an existing transaction. Unlike the +/// `create_protection_rule` handler (which rejects an existing name), this upserts, so it is safe to +/// call programmatically when designating a dev/prod pair. Callers MUST invalidate the +/// protection-rules cache (`invalidate_protection_rules_cache`) after the transaction commits. +async fn upsert_protection_rule( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, + name: &str, + rules: ProtectionRules, + bypass_groups: &[String], + bypass_users: &[String], +) -> Result<()> { + sqlx::query!( + r#" + INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (workspace_id, name) + DO UPDATE SET rules = EXCLUDED.rules, + bypass_groups = EXCLUDED.bypass_groups, + bypass_users = EXCLUDED.bypass_users + "#, + w_id, + name, + rules.bits(), + bypass_groups, + bypass_users, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Lock a prod workspace by applying the reserved dev-workspace lock rule with the selected +/// restrictions (block direct deployment and/or ad-hoc forking). Non-admins are then funneled +/// through the one dev workspace; admins bypass the rules (their existing escape hatch). +async fn lock_prod_workspace( + tx: &mut Transaction<'_, Postgres>, + prod_w_id: &str, + block_deploy: bool, + block_forking: bool, +) -> Result<()> { + let mut rules = Vec::new(); + if block_deploy { + rules.push(ProtectionRuleKind::DisableDirectDeployment); + } + if block_forking { + rules.push(ProtectionRuleKind::DisableWorkspaceForking); + } + if rules.is_empty() { + return Ok(()); + } + upsert_protection_rule( + tx, + prod_w_id, + DEV_WORKSPACE_LOCK_RULE_NAME, + ProtectionRules::from(&rules), + &[], + &[], + ) + .await +} + +/// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the +/// partial unique index `workspace_canonical_dev_idx` with a friendly message. +async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result<()> { + let existing = sqlx::query_scalar!( + "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + parent_w_id + ) + .fetch_optional(db) + .await?; + if let Some(existing) = existing { + return Err(Error::BadRequest(format!( + "Workspace '{}' already has a dev workspace ('{}'). Detach it before creating another.", + parent_w_id, existing + ))); + } + Ok(()) +} + +/// A dev workspace pairs with a root prod workspace; nesting dev workspaces (a dev of a dev) isn't +/// supported and would muddle the prod<->dev relationship. +async fn ensure_dev_parent_is_root(db: &DB, parent_w_id: &str) -> Result<()> { + let parent_is_fork = sqlx::query_scalar!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#, + parent_w_id + ) + .fetch_optional(db) + .await? + .unwrap_or(false); + if parent_is_fork { + return Err(Error::BadRequest(format!( + "Cannot create a dev workspace of '{}' because it is itself a fork or dev workspace.", + parent_w_id + ))); + } + Ok(()) +} + +/// `dev_workspace_lock` is owned by the dev-workspace feature (attach/detach/archive/delete create and +/// remove it by name). Reserve it from the public protection-rule API so a user-managed rule can't +/// collide: otherwise the feature's name-based cleanup would clobber the user's rule, or a manual edit +/// could weaken the feature's lock. +fn reject_reserved_rule_name(name: &str) -> Result<()> { + if name == DEV_WORKSPACE_LOCK_RULE_NAME { + return Err(Error::BadRequest(format!( + "'{}' is a reserved protection-rule name managed by the dev workspace feature", + DEV_WORKSPACE_LOCK_RULE_NAME + ))); + } + Ok(()) +} + /// Create a new protection rule async fn create_protection_rule( authed: ApiAuthed, @@ -6174,6 +6709,7 @@ async fn create_protection_rule( Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + reject_reserved_rule_name(&req.name)?; let mut tx = db.begin().await?; @@ -6248,6 +6784,7 @@ async fn update_protection_rule( Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + reject_reserved_rule_name(&rule_name)?; let mut tx = db.begin().await?; @@ -6322,6 +6859,7 @@ async fn delete_protection_rule( Path((w_id, rule_name)): Path<(String, String)>, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + reject_reserved_rule_name(&rule_name)?; let mut tx = db.begin().await?; @@ -6466,10 +7004,22 @@ async fn compare_workspaces( })); } + // Honor ws_specific at read time: a workspace-specific resource/variable keeps its own value per + // environment, so it must never appear in the normal diff (the per-item compare suppresses it, + // but a cached `has_changes=true` row is trusted without re-running that compare, so filter those + // here too). Seeding the initial copy onto a side that lacks it is a separate explicit action + // (the "Create in " button on the Workspace-specific list), not part of the diff. The row + // is left intact, so unpinning resurfaces it without a re-tally. let diff_items = sqlx::query_as!( WorkspaceDiffRow, "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff - WHERE source_workspace_id = $1 AND fork_workspace_id = $2", + WHERE source_workspace_id = $1 AND fork_workspace_id = $2 + AND NOT EXISTS ( + SELECT 1 FROM ws_specific ws + WHERE ws.path = workspace_diff.path + AND ws.item_kind = workspace_diff.kind + AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id) + )", source_workspace_id, fork_workspace_id, ) @@ -7197,7 +7747,6 @@ async fn compare_two_resources( .fetch_optional(db) .await?; - // If either side is ws_specific, consider unchanged let source_ws_specific = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)", source_workspace_id, @@ -7216,6 +7765,9 @@ async fn compare_two_resources( .await? .unwrap_or(false); + // A workspace-specific resource keeps its own value per environment, so it never appears in the + // diff (in either direction). Seeding the initial copy onto a side that lacks it is a separate + // explicit action ("Create in "), not a diff entry. if source_ws_specific || target_ws_specific { return Ok(ItemComparison { has_changes: false, @@ -7272,7 +7824,8 @@ async fn compare_two_variables( .fetch_one(db) .await?; - // If either side is ws_specific, consider unchanged + // A workspace-specific variable keeps its own value per environment, so it never appears in the + // diff. Seeding the initial copy onto a side that lacks it is a separate explicit action. if presence.src_ws || presence.tgt_ws { return Ok(ItemComparison { has_changes: false, @@ -7786,6 +8339,19 @@ async fn list_ws_specific( .fetch_all(&mut *tx) .await?; tx.commit().await?; + // RLS gates membership/folder access, but a scoped API token must also be held to its read + // scopes — mirror the resource/variable list endpoints, which filter with these predicates so a + // token lacking `resources:read:*` / `variables:read:*` can't enumerate pinned paths it can't read. + let resource_allowed = build_scope_path_predicate(&authed, "resources", "read"); + let variable_allowed = build_scope_path_predicate(&authed, "variables", "read"); + let items = items + .into_iter() + .filter(|it| match it.item_kind.as_str() { + "resource" => resource_allowed(&it.path), + "variable" => variable_allowed(&it.path), + _ => false, + }) + .collect::>(); Ok(Json(items)) } @@ -7808,6 +8374,17 @@ async fn list_ws_specific_versions( ))); } + // A scoped API token must hold the read scope for this path, like the resource/variable read + // endpoints. Without the scope, report no versions rather than leaking the path's history. + let domain = if q.kind == "resource" { + "resources" + } else { + "variables" + }; + if !build_scope_path_predicate(&authed, domain, "read")(&q.path) { + return Ok(Json(vec![])); + } + let versions: Vec = sqlx::query_scalar!( r#"SELECT ws AS "ws!" FROM list_ws_specific_versions($1, $2, $3, $4)"#, &w_id, @@ -7820,3 +8397,155 @@ async fn list_ws_specific_versions( Ok(Json(versions)) } + +#[derive(Deserialize)] +struct SetWsSpecificBody { + item_kind: String, + path: String, + value: bool, +} + +/// Mark (or unmark) a single resource/variable as workspace-specific. Pinning +/// excludes it from the deploy diff so each environment keeps its own value +/// (see `compare_two_resources`/`compare_two_variables`). Set per-workspace, so +/// the compare page calls this once per side to flag both environments. +async fn set_ws_specific( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path(w_id): Path, + Json(body): Json, +) -> Result { + if body.item_kind != "resource" && body.item_kind != "variable" { + return Err(Error::BadRequest(format!( + "Invalid kind '{}'. Must be 'resource' or 'variable'", + body.item_kind + ))); + } + + // Reject a malformed path with a 400 before the auth check, which indexes the leading segments and + // would otherwise panic (500) on a path like `u` with no segment. Accept all three shared path + // shapes Windmill uses — `u/`, `f/`, `g/` (e.g. seeded `g/all/...` resources). + let segs: Vec<&str> = body.path.split('/').collect(); + if segs.len() < 2 || !matches!(segs[0], "u" | "f" | "g") || segs[1].is_empty() { + return Err(Error::BadRequest(format!( + "Invalid {} path: {}", + body.item_kind, body.path + ))); + } + + // Authorize like the resource/variable editors' own ws_specific toggle: + // actual write access to the item + token scope + the workspace deploy rules. + // `require_owner_of_path` is the real write gate (the resource editor uses it); + // `check_scopes` only constrains scoped tokens (it is a no-op for session/cookie + // logins). Together: a non-admin who can edit the item may pin it, while a + // read-only member is rejected and a locked workspace still blocks non-deployers. + // `require_is_writer` matches the resource/variable editors' write semantics (owner, folder + // writer, or item writer via extra_perms) — not owner-only. + let writer_query = if body.item_kind == "resource" { + "SELECT extra_perms FROM resource WHERE path = $1 AND workspace_id = $2" + } else { + "SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2" + }; + require_is_writer( + &authed, + &body.path, + &w_id, + db.clone(), + writer_query, + &body.item_kind, + ) + .await?; + check_scopes(&authed, || { + format!("{}s:write:{}", body.item_kind, body.path) + })?; + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( + &w_id, + &authed.username, + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + + let mut tx = user_db.begin(&authed).await?; + + if body.value { + // Existence guard keeps a dangling marker from being created for a + // path absent in this workspace. + if body.item_kind == "resource" { + sqlx::query!( + "INSERT INTO ws_specific (workspace_id, item_kind, path) + SELECT $1::varchar, 'resource', $2::varchar + WHERE EXISTS (SELECT 1 FROM resource WHERE workspace_id = $1::varchar AND path = $2::varchar) + ON CONFLICT DO NOTHING", + w_id, + body.path, + ) + .execute(&mut *tx) + .await?; + // A resource owns its `$var:` secrets, so pin those too. + windmill_store::resources::mark_linked_variables_ws_specific( + &mut tx, &authed, &w_id, &body.path, + ) + .await?; + } else { + sqlx::query!( + "INSERT INTO ws_specific (workspace_id, item_kind, path) + SELECT $1::varchar, 'variable', $2::varchar + WHERE EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1::varchar AND path = $2::varchar) + ON CONFLICT DO NOTHING", + w_id, + body.path, + ) + .execute(&mut *tx) + .await?; + } + } else { + // Unmark only this item; linked variables stay flagged (they may be + // referenced by other resources) — mirrors the resource-form toggle. + sqlx::query!( + "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = $2 AND path = $3", + w_id, + body.item_kind, + body.path, + ) + .execute(&mut *tx) + .await?; + // While pinned, the item's cached workspace_diff verdict was never recomputed (the compare + // read filter excludes it), so it may now be stale in either direction. Mark it NULL so the + // next compare re-evaluates from scratch — and the now-shared item reappears (or is dropped) + // correctly instead of being stuck on its pre-pin verdict. + sqlx::query!( + "UPDATE workspace_diff SET has_changes = NULL + WHERE path = $2 AND kind = $3 + AND ($1 IN (source_workspace_id, fork_workspace_id))", + w_id, + body.path, + body.item_kind, + ) + .execute(&mut *tx) + .await?; + } + + let value_str = body.value.to_string(); + audit_log( + &mut *tx, + &authed, + &format!("{}s.set_ws_specific", body.item_kind), + ActionKind::Update, + &w_id, + Some(&body.path), + Some([("value", value_str.as_str())].into()), + ) + .await?; + + tx.commit().await?; + Ok(format!( + "Set workspace-specific={} for {} {}", + body.value, body.item_kind, body.path + )) +} diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 3cb2b29f79..47d8c24b92 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use windmill_api_auth::{require_super_admin, ApiAuthed}; -use windmill_common::workspaces::WM_FORK_PREFIX; use windmill_common::DB; use crate::workspaces::{ @@ -26,7 +25,7 @@ use windmill_common::{ db::UserDB, error::{Error, Result}, utils::require_admin, - workspaces::DataTable, + workspaces::{DataTable, DEV_WORKSPACE_LOCK_RULE_NAME, WM_FORK_PREFIX}, }; use windmill_queue::schedule::{get_schedule_opt, push_scheduled_job}; @@ -65,22 +64,42 @@ pub(crate) async fn change_workspace_id( old_id, rw.new_id ); - // Create new workspace with new id and name. A fork that keeps a wm-fork- - // id must carry its parent_workspace_id over, otherwise it becomes a - // parentless "fork of nothing" with no source to compare or merge against. - // A non-fork target id means the workspace is being promoted out of a fork, - // so the parent pointer is intentionally cleared. + // Create new workspace with new id and name. Fork lineage AND the dev designation are preserved + // from the source row, not inferred from the new id's prefix: a prefix-less fork (a dev or + // detached-dev workspace) would otherwise be silently promoted to a root workspace, and a dev + // would lose its flag — leaving its prod locked with no canonical dev. Promoting out of a fork is + // a separate, explicit action — a rename never does it implicitly. info!("Creating new workspace row"); - let new_is_fork = rw.new_id.starts_with(WM_FORK_PREFIX); + let old = sqlx::query!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!", is_dev_workspace + FROM workspace WHERE id = $1"#, + &old_id + ) + .fetch_optional(&mut *tx) + .await?; + let new_is_fork = old.as_ref().map(|o| o.has_parent).unwrap_or(false); + let new_is_dev = new_is_fork && old.as_ref().map(|o| o.is_dev_workspace).unwrap_or(false); + // Neutralize the old row's dev flag BEFORE inserting the new one: the move-and-archive archives + // the old row only later, so without this the new dev row and the not-yet-archived old dev row + // would momentarily both be active under the same parent and trip the one-dev-per-parent index. + if new_is_dev { + sqlx::query!( + "UPDATE workspace SET is_dev_workspace = false WHERE id = $1", + &old_id + ) + .execute(&mut *tx) + .await?; + } sqlx::query!( - "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id) + "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace) SELECT $1, $2, owner, false, premium, - CASE WHEN $4 THEN parent_workspace_id ELSE NULL END + CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5 FROM workspace WHERE id = $3", &rw.new_id, &rw.new_name, &old_id, - new_is_fork + new_is_fork, + new_is_dev ) .execute(&mut *tx) .await?; @@ -360,8 +379,18 @@ pub(crate) async fn change_workspace_id( // must follow the renamed parent to the new id, otherwise it is left // pointing at the soft-deleted old shell (whose data has moved here). info!("Re-parenting child forks to the new workspace id"); + let reparented_children: Vec = sqlx::query_scalar!( + "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2 RETURNING id", + &rw.new_id, + &old_id + ) + .fetch_all(&mut *tx) + .await?; + + // A dev/fork's `deploy_to` points at the prod root, so it must follow the rename too — otherwise + // the child re-parents to the new id but still deploys to the soft-deleted old shell. sqlx::query!( - "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2", + "UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2", &rw.new_id, &old_id ) @@ -696,11 +725,18 @@ pub(crate) async fn change_workspace_id( tx.commit().await?; + // The children's parent_workspace_id changed (old root -> new root); invalidate their fork-parent + // routing cache so jobs route under the renamed root rather than the old (archived) one until the + // 300s TTL would otherwise expire. + for child in &reparented_children { + windmill_queue::tags::invalidate_fork_parent_cache(child); + } + // Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true // Note: schedules were already moved to new workspace, so this will find 0 schedules info!("Archiving old workspace"); let (_schedules_count, canceled_count, _deleted_tokens_count) = - archive_workspace_impl(&db, &old_id, &authed.username).await?; + archive_workspace_impl(&db, &old_id, &authed.username, None).await?; info!( "Workspace id change completed: moved {} to {}, archived old workspace", @@ -734,15 +770,65 @@ pub(crate) async fn delete_workspace( _ => Ok(w_id), }?; - if dwq.only_delete_forks.unwrap_or(false) && !w_id.starts_with(WM_FORK_PREFIX) { + let is_fork = workspace_is_fork(&db, &w_id).await?; + if dwq.only_delete_forks.unwrap_or(false) && !is_fork { return Err(Error::BadRequest( "Cannot delete this workspace because it is not a workspace fork.".to_string(), )); } let mut tx = db.begin().await?; - if !(w_id.starts_with(WM_FORK_PREFIX) && is_workspace_owner(&authed, &w_id, &mut tx).await?) { - require_super_admin(&db, &authed.email).await?; + if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?) + && !is_super_admin_email(&db, &authed.email).await? + { + return Err(Error::PermissionDenied( + "Deleting this workspace requires being the fork's owner or a superadmin".to_string(), + )); + } + + // Don't hard-delete a workspace that still has a dev workspace paired to it: the FK is + // ON DELETE SET NULL, which would orphan the (prefix-less) dev into a parentless, non-fork row + // its owner could no longer self-delete. Require detaching/deleting the dev first. Ordinary + // forks have no such guard — they keep their prefix and stay owner-deletable when orphaned. + // Archived devs (deleted = true) are included: they keep is_dev_workspace = true, so SET NULL on + // their parent would violate the `is_dev ⇒ has parent` CHECK and fail the whole delete with a 500. + if let Some(dev_id) = sqlx::query_scalar!( + "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace", + &w_id + ) + .fetch_optional(&mut *tx) + .await? + { + return Err(Error::BadRequest(format!( + "Cannot delete workspace '{}' because it has a dev workspace ('{}'). Detach or delete the dev workspace first.", + w_id, dev_id + ))); + } + + // Deleting an attached dev workspace removes the parent prod's dev_workspace_lock (below), so it + // must be a prod-admin action, not just the dev's own owner (dev ownership can diverge from + // prod's) — mirrors detach_dev_workspace, which is prod-admin gated. + if let Some(prod) = sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten() + { + let is_prod_admin = sqlx::query_scalar!( + "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", + &prod, + &authed.email + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false); + if !is_prod_admin && !is_super_admin_email(&db, &authed.email).await? { + return Err(Error::PermissionDenied(format!( + "Deleting dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)" + ))); + } } sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id) @@ -920,10 +1006,31 @@ pub(crate) async fn delete_workspace( .execute(&mut *tx) .await?; + // If this workspace is itself a dev workspace, deleting it dissolves the pairing, so also drop + // the parent prod's reserved dev_workspace_lock (mirrors detach_dev_workspace) — otherwise prod + // stays locked against direct deploy/forking with no dev workspace left to make changes in. + let dev_lock_parent: Option = sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id) .execute(&mut *tx) .await?; + if let Some(ref parent) = dev_lock_parent { + sqlx::query!( + "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2", + parent, + DEV_WORKSPACE_LOCK_RULE_NAME + ) + .execute(&mut *tx) + .await?; + } + // Record under the instance-level "admins" workspace. The per-workspace audit // rows are deleted along with the workspace, so this instance-level entry is the // only durable, superadmin-discoverable record of who deleted the workspace. @@ -939,6 +1046,10 @@ pub(crate) async fn delete_workspace( .await?; tx.commit().await?; + if let Some(parent) = dev_lock_parent { + windmill_common::workspaces::invalidate_protection_rules_cache(&parent); + } + Ok(format!("Deleted workspace {}", &w_id)) } @@ -957,9 +1068,15 @@ pub async fn drop_forked_datatable_databases( Json(req): Json, ) -> Result>> { // Same permission check as delete_workspace: fork owner or super admin + let is_fork = workspace_is_fork(&db, &w_id).await?; let mut tx = db.begin().await?; - if !(w_id.starts_with(WM_FORK_PREFIX) && is_workspace_owner(&authed, &w_id, &mut tx).await?) { - require_super_admin(&db, &authed.email).await?; + if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?) + && !is_super_admin_email(&db, &authed.email).await? + { + return Err(Error::PermissionDenied( + "Dropping forked datatable databases requires being the fork's owner or a superadmin" + .to_string(), + )); } tx.commit().await?; @@ -1103,3 +1220,21 @@ async fn is_workspace_owner( .await?; Ok(owner.map(|o| o == authed.email).unwrap_or(false)) } + +/// Whether a workspace is a fork or dev workspace. Both forks and dev workspaces set +/// `parent_workspace_id`, but a `wm-fork-` workspace can outlive its parent (the FK is +/// `ON DELETE SET NULL`), so also treat the prefix as fork-ness — otherwise an orphaned fork would +/// lose owner-self-delete. Used to gate owner-self-delete, which is permitted for forks/dev +/// workspaces but requires superadmin otherwise. +async fn workspace_is_fork(db: &DB, w_id: &str) -> Result { + if w_id.starts_with(WM_FORK_PREFIX) { + return Ok(true); + } + Ok(sqlx::query_scalar!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#, + w_id + ) + .fetch_optional(db) + .await? + .unwrap_or(false)) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 18b1914826..f3e87c3638 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1184,6 +1184,89 @@ paths: schema: type: string + /w/{workspace}/workspaces/attach_dev_workspace: + post: + summary: attach an existing workspace as the dev workspace of this (prod) workspace + operationId: attachDevWorkspace + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dev_workspace_id: + type: string + lock_prod_deploy: + type: boolean + lock_prod_forking: + type: boolean + required: + - dev_workspace_id + responses: + "200": + description: dev workspace attached + content: + text/plain: + schema: + type: string + + /w/{workspace}/workspaces/detach_dev_workspace: + post: + summary: detach the dev workspace from this (prod) workspace + operationId: detachDevWorkspace + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dev_workspace_id: + type: string + required: + - dev_workspace_id + responses: + "200": + description: dev workspace detached + content: + text/plain: + schema: + type: string + + /w/{workspace}/workspaces/get_dev_workspace: + get: + summary: this workspace's active canonical dev workspace, if any + operationId: getDevWorkspace + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: the dev workspace, or null if none + content: + application/json: + schema: + nullable: true + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + /workspaces/exists: post: summary: exists workspace @@ -6238,6 +6321,42 @@ paths: items: type: string + /w/{workspace}/workspaces/set_ws_specific: + post: + summary: mark or unmark an item as workspace-specific + operationId: setWsSpecific + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + item_kind: + type: string + enum: + - resource + - variable + path: + type: string + value: + type: boolean + required: + - item_kind + - path + - value + responses: + "200": + description: workspace-specific flag updated + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/public_app_rate_limit: post: summary: Set public app rate limit for this workspace @@ -27756,6 +27875,8 @@ components: parent_workspace_id: type: string nullable: true + is_dev_workspace: + type: boolean created_by: type: string nullable: true @@ -27766,6 +27887,7 @@ components: - name - username - color + - is_dev_workspace - disabled required: - email @@ -27807,6 +27929,18 @@ components: new_dbname: type: string description: "New database name for the fork" + is_dev_workspace: + type: boolean + description: "Create the fork as a persistent dev workspace (id not required to carry the wm-fork- prefix; at most one per parent)" + lock_prod_deploy: + type: boolean + description: "When creating a dev workspace, lock the parent (prod) against direct deployment" + lock_prod_forking: + type: boolean + description: "When creating a dev workspace, prevent forking the parent (prod)" + copy_members: + type: boolean + description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it" required: - id - name diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 20b9b4e082..73d95a3af9 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -179,7 +179,24 @@ pub const WM_FORK_PREFIX: &str = "wm-fork-"; /// layer because the actual branch creation runs in a deferred git-sync worker job — without /// this check, the API returns 200 and the failure only surfaces later in the worker. pub fn validate_fork_workspace_id(id: &str) -> error::Result<()> { - if !id.starts_with(WM_FORK_PREFIX) { + validate_workspace_branch_id(id, true) +} + +/// Like [`validate_fork_workspace_id`] but does not require the `wm-fork-` prefix. Used for dev +/// workspaces, whose id is an ordinary (prefix-less) workspace id but must still be git-branch-safe +/// because it is interpolated into a `wm-fork//` branch name like any fork. +pub fn validate_dev_workspace_id(id: &str) -> error::Result<()> { + validate_workspace_branch_id(id, false) +} + +fn validate_workspace_branch_id(id: &str, require_fork_prefix: bool) -> error::Result<()> { + if id.is_empty() { + return Err(Error::BadRequest( + "Workspace id cannot be empty".to_string(), + )); + } + + if require_fork_prefix && !id.starts_with(WM_FORK_PREFIX) { return Err(Error::BadRequest(format!( "The id `{}` is invalid for a forked workspace. It should be prefixed by {}", id, WM_FORK_PREFIX @@ -188,8 +205,9 @@ pub fn validate_fork_workspace_id(id: &str) -> error::Result<()> { if id.len() > 50 { return Err(Error::BadRequest(format!( - "Fork workspace id `{}` is too long ({} chars). Maximum length is 50 characters (including the '{}' prefix).", - id, id.len(), WM_FORK_PREFIX + "Workspace id `{}` is too long ({} chars). Maximum length is 50 characters.", + id, + id.len() ))); } @@ -438,6 +456,11 @@ pub enum RuleCheckResult { /// - User is not in bypass lists /// /// Returns `Err` if the rule is not found +/// Reserved protection-rule name applied to a prod workspace paired with a dev workspace. It carries +/// `DisableDirectDeployment` + `DisableWorkspaceForking` and is auto-managed by the dev-workspace +/// feature (applied on pairing, removed on detach). +pub const DEV_WORKSPACE_LOCK_RULE_NAME: &str = "dev_workspace_lock"; + pub async fn check_user_against_rule( workspace_id: &str, rule: &ProtectionRuleKind, @@ -795,6 +818,30 @@ mod tests { assert!(validate_fork_workspace_id("wm-fork-foo/bar.lock").is_err()); } + #[test] + fn test_validate_dev_workspace_id_accepts_prefixless_valid() { + // Dev workspaces use ordinary, prefix-less ids but must stay git-branch-safe. + validate_dev_workspace_id("dev").unwrap(); + validate_dev_workspace_id("my-dev-workspace").unwrap(); + validate_dev_workspace_id("staging.42").unwrap(); + // The fork prefix is allowed but not required. + validate_dev_workspace_id("wm-fork-dev").unwrap(); + } + + #[test] + fn test_validate_dev_workspace_id_rejects_empty_and_git_unsafe() { + assert!(validate_dev_workspace_id("").is_err()); + assert!(validate_dev_workspace_id("dev workspace").is_err()); + assert!(validate_dev_workspace_id("dev..staging").is_err()); + assert!(validate_dev_workspace_id("dev/.x").is_err()); + assert!(validate_dev_workspace_id("dev.lock").is_err()); + } + + #[test] + fn test_validate_fork_workspace_id_rejects_empty() { + assert!(validate_fork_workspace_id("").is_err()); + } + #[test] fn test_validate_fork_workspace_id_rejects_too_long() { let long_id = format!("wm-fork-{}", "a".repeat(43)); diff --git a/backend/windmill-queue/src/tags.rs b/backend/windmill-queue/src/tags.rs index a0cc3a305e..d80e50d8b2 100644 --- a/backend/windmill-queue/src/tags.rs +++ b/backend/windmill-queue/src/tags.rs @@ -8,13 +8,21 @@ const FORK_PARENT_CACHE_TTL_SECS: u64 = 300; lazy_static::lazy_static! { // Cache of fork workspace id -> (parent_workspace_id, cached_at). - // `parent_workspace_id` is essentially immutable once a fork is created, so a multi-minute TTL - // is safe. `None` means the lookup found no parent (or the DB call failed); we still cache it - // briefly so that forks missing a parent do not hammer the DB. + // `parent_workspace_id` is stable for the lifetime of a fork EXCEPT across attach/detach of a + // dev workspace, which set/keep it; those paths call `invalidate_fork_parent_cache` so routing + // doesn't lag. `None` means the lookup found no parent (or the DB call failed); we still cache + // it briefly so that forks missing a parent do not hammer the DB. static ref FORK_PARENT_CACHE: quick_cache::sync::Cache, std::time::Instant)> = quick_cache::sync::Cache::new(500); } +/// Drop the cached fork->parent mapping for a workspace. Call after mutating `parent_workspace_id` +/// (attaching/detaching a dev workspace) so per-workspace job tags resolve to the new parent +/// immediately instead of after the cache TTL. +pub fn invalidate_fork_parent_cache(workspace_id: &str) { + FORK_PARENT_CACHE.remove(workspace_id); +} + /// Returns `Some(effective_workspace_tag_id)` if jobs of `workspace_id` should use workspace- /// specific tags, where `effective_workspace_tag_id` is the string embedded in the tag. For forks, /// this is always the parent workspace id, optionally suffixed with `-fork` (controlled by the @@ -26,16 +34,14 @@ pub async fn per_workspace_tag(workspace_id: &str, db: &Pool) -> Optio return None; } - let is_fork = workspace_id.starts_with(WM_FORK_PREFIX); - - // For forks, always resolve to the parent workspace id; regular workspaces avoid the lookup. - let effective_ws_id: String = if is_fork { - lookup_fork_parent(workspace_id, db) - .await - .unwrap_or_else(|| workspace_id.to_string()) // no parent found -> fall back to fork's own id - } else { - workspace_id.to_string() - }; + // Resolve to the parent workspace id when the workspace is a fork or dev workspace (both set + // parent_workspace_id). The lookup caches its `None` result, so non-forks stay cheap after warmup + // (and the common case is already short-circuited by the global toggle above). + let parent = lookup_fork_parent(workspace_id, db).await; + // A `wm-fork-` workspace can outlive its parent (the FK is `ON DELETE SET NULL`), so keep + // treating the prefix as fork-ness for the `-fork` suffix even when the parent link is gone. + let is_fork = parent.is_some() || workspace_id.starts_with(WM_FORK_PREFIX); + let effective_ws_id: String = parent.unwrap_or_else(|| workspace_id.to_string()); // Whitelist check is against the resolved (parent) id so that including a parent in the // whitelist transparently covers all of its forks. @@ -62,8 +68,10 @@ pub async fn per_workspace_tag(workspace_id: &str, db: &Pool) -> Optio }) } -/// Returns the parent workspace id for a fork, or `None` if the fork has no parent set (or the -/// DB lookup failed). Backed by a short-TTL cache to avoid a DB round-trip per job push. +/// Returns the parent workspace id for a fork, or `None` if the fork has no parent set. Backed by a +/// short-TTL cache to avoid a DB round-trip per job push. A transient DB error returns `None` for +/// this call but is NOT cached, so the next push retries instead of misrouting a (prefix-less) dev +/// workspace's jobs for the whole TTL. async fn lookup_fork_parent(fork_id: &str, db: &Pool) -> Option { if let Some((parent, cached_at)) = FORK_PARENT_CACHE.get(fork_id) { if cached_at.elapsed().as_secs() < FORK_PARENT_CACHE_TTL_SECS { @@ -78,8 +86,11 @@ async fn lookup_fork_parent(fork_id: &str, db: &Pool) -> Option Some(parent), - _ => None, + Ok(opt) => opt.flatten(), + Err(e) => { + tracing::warn!("failed to look up fork parent for {fork_id}: {e:#}"); + return None; + } }; FORK_PARENT_CACHE.insert( diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 7da6d75f4b..98f91065e4 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -1088,21 +1088,48 @@ async fn create_resource( .execute(&db) .await?; } - sqlx::query!( - "INSERT INTO resource - (workspace_id, path, value, description, resource_type, created_by, edited_at, labels) - VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) - DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels", - w_id, - resource.path, - raw_json as sqlx::types::Json<&RawValue>, - resource.description, - resource.resource_type, - authed.username, - resource.labels.as_deref() as Option<&[String]> - ) - .execute(&mut *tx) - .await?; + if update_if_exists { + sqlx::query!( + "INSERT INTO resource + (workspace_id, path, value, description, resource_type, created_by, edited_at, labels) + VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) + DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels", + w_id, + resource.path, + raw_json as sqlx::types::Json<&RawValue>, + resource.description, + resource.resource_type, + authed.username, + resource.labels.as_deref() as Option<&[String]> + ) + .execute(&mut *tx) + .await?; + } else { + // Create-only (the default): DO NOTHING + a row-count guard, so a path that appears between + // check_path_conflict above and this insert is rejected rather than overwritten. A plain + // DO UPDATE here would clobber a concurrently-created resource, breaking create-only callers + // (e.g. Compare & Deploy "Create in "). + let inserted = sqlx::query!( + "INSERT INTO resource + (workspace_id, path, value, description, resource_type, created_by, edited_at, labels) + VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) DO NOTHING", + w_id, + resource.path, + raw_json as sqlx::types::Json<&RawValue>, + resource.description, + resource.resource_type, + authed.username, + resource.labels.as_deref() as Option<&[String]> + ) + .execute(&mut *tx) + .await?; + if inserted.rows_affected() == 0 { + return Err(Error::BadRequest(format!( + "Resource {} already exists", + resource.path + ))); + } + } // Mirror update_resource: Some(true) inserts, Some(false) clears (only // meaningful on the upsert path, since a pure create has no existing row), @@ -1385,7 +1412,12 @@ fn collect_var_refs(value: &serde_json::Value, out: &mut Vec) { } } -async fn mark_linked_variables_ws_specific( +/// Marks every variable referenced by the resource at `resource_path` as workspace-specific. +/// +/// AUTH CONTRACT: this mutates `ws_specific` and does NOT check authorization itself. The caller +/// MUST verify that `authed` has write access to the resource at `resource_path` in `w_id` (e.g. via +/// `require_owner_of_path`) before calling it. +pub async fn mark_linked_variables_ws_specific( tx: &mut Transaction<'_, Postgres>, authed: &ApiAuthed, w_id: &str, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0d66dfed67..95c8f11608 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -878,7 +878,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -890,7 +889,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -901,7 +899,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1417,7 +1414,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1566,7 +1562,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1583,7 +1578,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1600,7 +1594,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1617,7 +1610,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1634,7 +1626,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1651,7 +1642,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1668,7 +1658,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1685,7 +1674,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1702,7 +1690,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1719,7 +1706,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1736,7 +1722,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1753,7 +1738,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1770,7 +1754,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1789,7 +1772,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1806,7 +1788,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2112,7 +2093,6 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7348,7 +7328,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7883,7 +7863,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7904,7 +7883,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7925,7 +7903,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7946,7 +7923,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7967,7 +7943,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7988,7 +7963,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8009,7 +7983,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8030,7 +8003,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8051,7 +8023,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8072,7 +8043,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8093,7 +8063,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12781,21 +12750,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -13535,7 +13489,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 06d787095e..a9d36cd6f7 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -20,8 +20,10 @@ AppService, FlowService, FolderService, + ResourceService, ScriptService, UserService, + VariableService, WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff @@ -97,6 +99,243 @@ onChanged }: Props = $props() + // Workspace-specific ("pinned") resources/variables. They keep their own + // value per environment and are suppressed from the diff, so the page fetches + // them separately to keep them visible and un-pinnable. + // `onCurrent`/`onParent` track which side carries the ws_specific marker — which, because marks + // are existence-guarded, also tells us which side the item exists on. An item present on only one + // side can be seeded onto the other via "Create in ". + type PinnedItem = { item_kind: string; path: string; onCurrent: boolean; onParent: boolean } + let pinnedItems = $state([]) + let pinBusy = $state>({}) + let createConfirm = $state(undefined) + + // Whether this item is already workspace-specific (marked on either side); used to avoid offering + // to pin it again from a diff row. + const isPinned = (kind: string, path: string) => + pinnedItems.some((it) => it.item_kind === kind && it.path === path) + + // Bumped on each load so a response that resolves after the compared pair + // changed can't overwrite the newer pair's items. + let pinnedReqSeq = 0 + async function loadPinned() { + const seq = ++pinnedReqSeq + try { + const [cur, par] = await Promise.all([ + WorkspaceService.listWsSpecific({ workspace: currentWorkspaceId }), + WorkspaceService.listWsSpecific({ workspace: parentWorkspaceId }) + ]) + if (seq !== pinnedReqSeq) return + const map = new Map() + const mark = (it: { item_kind: string; path: string }, side: 'cur' | 'par') => { + const k = `${it.item_kind}:${it.path}` + const e = map.get(k) ?? { + item_kind: it.item_kind, + path: it.path, + onCurrent: false, + onParent: false + } + if (side === 'cur') e.onCurrent = true + else e.onParent = true + map.set(k, e) + } + for (const it of cur) mark(it, 'cur') + for (const it of par) mark(it, 'par') + pinnedItems = [...map.values()].sort((a, b) => + `${a.item_kind}:${a.path}`.localeCompare(`${b.item_kind}:${b.path}`) + ) + } catch (e) { + console.error('Failed to load workspace-specific items', e) + } + } + + $effect(() => { + // Re-fetch whenever the compared pair changes. + currentWorkspaceId + parentWorkspaceId + loadPinned() + }) + + // Flag both environments so each side marks the item workspace-specific, not + // only the workspace the compare is viewed from. `value=false` makes it + // shared again. + async function setPinned(kind: 'resource' | 'variable', path: string, value: boolean) { + const k = `${kind}:${path}` + pinBusy[k] = true + try { + const targets = [currentWorkspaceId, parentWorkspaceId] + const results = await Promise.allSettled( + targets.map((ws) => + WorkspaceService.setWsSpecific({ + workspace: ws, + requestBody: { item_kind: kind, path, value } + }) + ) + ) + const verb = value ? 'workspace specific' : 'shared' + const failed = results.filter((r) => r.status === 'rejected').length + if (failed === targets.length) { + sendUserToast(`Failed to update workspace-specific for ${path}`, true) + } else if (failed > 0) { + sendUserToast(`Made ${path} ${verb} in one environment only (no access to the other)`) + } else { + sendUserToast(`Made ${path} ${verb}`) + } + await loadPinned() + onChanged?.() + } finally { + pinBusy[k] = false + } + } + + // Collect `$var:` variable references from a resource value (mirrors the backend `collect_var_refs`). + function collectVarRefs(value: unknown, out: string[]) { + if (typeof value === 'string') { + if (value.startsWith('$var:')) out.push(value.slice('$var:'.length)) + } else if (Array.isArray(value)) { + for (const v of value) collectVarRefs(v, out) + } else if (value && typeof value === 'object') { + for (const v of Object.values(value)) collectVarRefs(v, out) + } + } + + // Create the item on the target from the source value, strictly create-only: the backend create + // endpoints reject an existing path (`check_path_conflict`), so unlike `deployItem` — which updates + // on conflict — this never overwrites a target created concurrently. Returns 'created', 'conflict' + // (already present on the target), or throws for a real error. + async function createItemOnly( + kind: 'resource' | 'variable', + path: string, + from: string, + to: string + ): Promise<'created' | 'conflict'> { + try { + if (kind === 'resource') { + const r = await ResourceService.getResource({ workspace: from, path }) + await ResourceService.createResource({ + workspace: to, + requestBody: { + path, + value: r.value ?? '', + description: r.description ?? '', + resource_type: r.resource_type + } + }) + } else { + const v = await VariableService.getVariable({ workspace: from, path, decryptSecret: true }) + await VariableService.createVariable({ + workspace: to, + requestBody: { + path, + value: v.value ?? '', + is_secret: v.is_secret ?? false, + description: v.description ?? '' + } + }) + } + return 'created' + } catch (e) { + // Don't parse the error message: re-check existence. If the target now exists, the create + // lost a race (the backend create is create-only and rejected it), so report a conflict and + // leave it untouched rather than overwriting. + const existsNow = + kind === 'resource' + ? await ResourceService.existsResource({ workspace: to, path }) + : await VariableService.existsVariable({ workspace: to, path }) + if (existsNow) return 'conflict' + throw e + } + } + + // Seed a resource's linked `$var:` variables into the target environment, copying each one's value + // (incl. secrets) only where the target lacks it (create-only). Mirrors the backend's + // `mark_linked_variables_ws_specific` cascade so a seeded resource resolves its references and its + // secrets stay per-environment. Returns the paths that failed to seed. + async function seedMissingLinkedVars( + resourcePath: string, + from: string, + to: string + ): Promise { + const resource = await ResourceService.getResource({ workspace: from, path: resourcePath }) + const refs: string[] = [] + collectVarRefs(resource.value, refs) + const failed: string[] = [] + for (const varPath of Array.from(new Set(refs))) { + if (await VariableService.existsVariable({ workspace: to, path: varPath })) continue + try { + // 'created' and 'conflict' (now present, created concurrently) are both the desired end + // state; only a real error counts as a failure to seed. + await createItemOnly('variable', varPath, from, to) + } catch (e) { + failed.push(varPath) + } + } + return failed + } + + // Seed a workspace-specific item onto a side that lacks it, copying its current value (incl. + // secret values). Strictly create-only: markers can be one-sided (a pin can fail on a locked + // side), so a missing marker doesn't prove the item is missing — re-check actual existence and, + // if the target already has it, mark it workspace-specific there instead of overwriting its value. + async function createOnRemote(it: PinnedItem) { + const from = it.onCurrent ? currentWorkspaceId : parentWorkspaceId + const to = it.onCurrent ? parentWorkspaceId : currentWorkspaceId + const k = `${it.item_kind}:${it.path}` + const kindCast = it.item_kind as 'resource' | 'variable' + pinBusy[k] = true + try { + const existsOnTarget = + kindCast === 'resource' + ? await ResourceService.existsResource({ workspace: to, path: it.path }) + : await VariableService.existsVariable({ workspace: to, path: it.path }) + if (existsOnTarget) { + // Don't overwrite — just mark the existing target item workspace-specific. + await WorkspaceService.setWsSpecific({ + workspace: to, + requestBody: { item_kind: kindCast, path: it.path, value: true } + }) + sendUserToast( + `${it.path} already exists in ${to} — marked it workspace-specific instead of overwriting` + ) + await loadPinned() + onChanged?.() + return + } + // A resource owns its `$var:` secrets, so seed any linked variables the target lacks before + // creating the resource — otherwise it would reference variables that don't exist there. If + // any linked variable fails to seed, abort rather than create a resource with dangling refs. + const linkedFailed = + kindCast === 'resource' ? await seedMissingLinkedVars(it.path, from, to) : [] + if (linkedFailed.length > 0) { + sendUserToast( + `Did not create ${it.path} in ${to}: failed to seed linked variable(s) ${linkedFailed.join(', ')}`, + true + ) + return + } + + // Create-only: a 'conflict' means the target appeared between the existence check above and + // the create, so it was left untouched rather than overwritten. + const result = await createItemOnly(kindCast, it.path, from, to) + // Marking cascades to mark a resource's now-present linked variables workspace-specific. + await WorkspaceService.setWsSpecific({ + workspace: to, + requestBody: { item_kind: kindCast, path: it.path, value: true } + }) + sendUserToast( + result === 'conflict' + ? `${it.path} already exists in ${to}, marked it workspace-specific instead of overwriting` + : `Created ${it.path} in ${to}` + ) + await loadPinned() + onChanged?.() + } catch (e) { + sendUserToast(`Failed to create ${it.path}: ${e}`, true) + } finally { + pinBusy[k] = false + } + } + // A fork row has a pending draft when its key is in the page-provided set. function hasDraft(diff: WorkspaceItemDiff): boolean { return draftKeys.has(getItemKey(diff)) @@ -1016,6 +1255,27 @@ Show diff + {#if diff.kind === 'resource' || diff.kind === 'variable'} + {#if isPinned(diff.kind, diff.path)} + + workspace-specific + + {:else} + + {/if} + {/if} {/if} {/snippet} @@ -1097,6 +1357,53 @@
+ + {#if pinnedItems.length > 0} +
+
+ Workspace-specific items +
+

+ These resources and variables keep their own value in each environment and are excluded + from the diff. An item that exists on only one side can be seeded onto the other with + "Create in …" (copies the current value, including secrets); it will never overwrite an + existing value. +

+
+ {#each pinnedItems as it (`${it.item_kind}:${it.path}`)} + {@const missingSide = + it.onCurrent && !it.onParent + ? parentWorkspaceId + : !it.onCurrent && it.onParent + ? currentWorkspaceId + : undefined} +
+ {it.item_kind} + {it.path} + {#if missingSide} + + {/if} + +
+ {/each} +
+
+ {/if} @@ -1129,6 +1436,27 @@ + + { + const it = createConfirm + createConfirm = undefined + if (it) createOnRemote(it) + }} + onCanceled={() => (createConfirm = undefined)} + > +

+ This copies the current value of {createConfirm?.path} + (including any secret value) from + {createConfirm?.onCurrent ? currentWorkspaceId : parentWorkspaceId} + into {createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}. It stays + workspace-specific afterward, so later promotes won't overwrite it. If it already exists + there, it's left untouched and just marked workspace-specific. +

+
{:else}
No comparison data available
diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte new file mode 100644 index 0000000000..bdba23c57f --- /dev/null +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -0,0 +1,191 @@ + + +{#if isDev && parentId} +
+

+ This is a dev workspace paired with root workspace {parentId}. Promote changes + from the home page banner or the Compare & Deploy page. +

+
+ +
+
+{:else if pairedDev} +
+

+ This workspace's dev workspace is {pairedDev.name} ({pairedDev.id}). Edits to this + workspace are redirected there. +

+
+ {#if pairedDev.isMember} + + {/if} + +
+
+{:else if parentId} +

+ Dev workspace pairing is only available for root workspaces. This workspace is a fork of + {parentId}. +

+{:else} +
+

+ Pair this workspace with a dev workspace: the same code with a different environment (resource + and variable values). Edits are made in the dev workspace and promoted here. +

+
+ Attach an existing workspace as dev +