From 0bf200d26c013a7a6720ec9510d8545ac6ebb633 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Wed, 10 Sep 2025 19:42:45 +0200 Subject: [PATCH] feat: forkables workspaces v0 (#6479) * Add create_ephemeral workspace endpoint * Add cli devShell * List ephemeral workspaces + improve endpoint * Add postgres function to clone a workspace (to be revisited) * Clone workspace using the postgres function * Add first iteration of ephemeral workspaces command * Update display of forked workspaces * Remove SQLX_OFFLINE * Add UI to create ephemeral workspace * Add option to exclude repository from being inherited to forks * WIP: reworking cloning logic * Fix cloning * Fix redirect after creating fork * Clean up cloning behaviour * Rename ephemeral to fork * emove ephemeral_workspaces table in favour of columns in workspaces * Fix display of forked workspaces * Fix skip inherit git sync repo setting * Fix fork invite display + creating fork as user * Fix SideMenu bug * Fix alignment * Simplify migrations * Update deletion of workspaces * Delete forked workspace from cli * Deleting fork workspaces from the UI as non-admin * Update cli sync and fork creation to adapt to branches and forks * Update fork prefix * Remove skip tracking toggle * Fix npm check warnings * Fix last npm check * fix: force stdin to Stdio::null for all user code execution (#6575) Set stdin to Stdio::null for all Commands that execute user code across all supported languages to prevent unwanted input consumption. This affects Python, Deno, Bash, PowerShell, Go, Rust, PHP, Ruby, Java, C#, Ansible, Nu, and Bun executors. The dedicated worker handler was intentionally left unchanged as it requires stdin for inter-process communication. Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: windmill-internal-app[bot] * Update ee-repo ref * Update SQLx metadata * Fix typos --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: windmill-internal-app[bot] --- ...f7f387f5055c47f493271d26731336257384.json} | 10 +- ...71d524fe9d4b46c2e8ed36ae99b5810a896ab.json | 18 + ...07e38f0c32953c7e881e916c389255190b72d.json | 17 + ...fefa362cdaf79d4f4a32b6d929701b68f4d1c.json | 70 ++ ...24263d87163a93a999c0389400dea6c9ec6f6.json | 15 + ...0490e61504484b560865006700030dadfbbcf.json | 27 + ...3c2a6a1378d87c0e2f0b5c9cda882b52e3eb0.json | 16 + ...f87437ac0e47f59eb9d15ad07856a690319ce.json | 46 + ...bfe4f063ef7615ac6b18384b345046bb8f4b.json} | 10 +- ...7a40ef42ca940ca4ff7f9fb2e69e63d830d27.json | 70 ++ ...589a19cbf7f9a7fb14430d82bb0658934b7a.json} | 12 +- ...f02dfbb892db94f464868dda38a0c6fe98bf6.json | 15 + ...030d2fe256d647a0422ba40dfe15a513dc04d.json | 18 + ...bcc7a07689c499e9f7be05d8b25e38671916d.json | 46 + ...a6a1cd42a79852952115e6f30db0dc6ee4b6e.json | 16 + ...6be271b0edf1c41b0cebee58440814bfbe2f1.json | 15 + ...e7980b81861af63ba116630603087196ce02b.json | 16 + ...fb3f93779c528c235dda83b68beea71463f8e.json | 22 + ...1abb820df9d6ec54a340cf8b1332e1052f666.json | 271 ++++++ ...74de478b1416eec70c1579bd782b36ca65206.json | 15 + ...e950d0e8ae30d7873dd4a55da6a5aa58c2aba.json | 15 + ...c7fe0a959446ed58a6033e0016fbf509ca934.json | 22 + ...e36dc8e96a63c4690721ae0bc169a3d83aff1.json | 22 + ...ac9a2b9ce8295e4ac85421091ab7da17a9a7c.json | 16 + ...7a203e1d7aec995ad5385f99e18a5e200d866.json | 58 ++ ...f5928a3c7eba2983431844581fd2393312a2e.json | 15 + ...033837d574a1ec72dfe42b666172daf10880.json} | 12 +- ...0529ca2731a3a00110709024efc80f5b25cc5.json | 29 + ...3b72e3323f878387de8b6fa31493f1215a77.json} | 10 +- ...187299c0b3f68373cf69e2fce08fdeaf185aa.json | 46 + ...931837dd6fb319577ba9d6103fb88b807bfca.json | 19 + ...bbc3fad75c097ed483adba1f2f7f593481807.json | 95 ++ ...4bee393e24f6f39f67e0b9e20e9cb8f04244c.json | 17 + ...da00b767c58f31cb711acb738733a89765234.json | 16 + ...17e2966e7a90554739d7554db5706ba355a3d.json | 14 + backend/ee-repo-ref.txt | 2 +- ...826120000_add_parent_to_workspace.down.sql | 7 + ...50826120000_add_parent_to_workspace.up.sql | 8 + backend/windmill-api/openapi.yaml | 55 ++ backend/windmill-api/src/users.rs | 9 +- backend/windmill-api/src/workspaces.rs | 915 +++++++++++++++++- backend/windmill-api/src/workspaces_extra.rs | 18 +- cli/src/commands/workspace/fork.ts | 243 +++++ cli/src/commands/workspace/workspace.ts | 7 +- cli/src/core/conf.ts | 30 +- cli/src/core/context.ts | 58 +- cli/src/main.ts | 2 + cli/src/utils/git.ts | 29 + flake.nix | 29 +- .../src/lib/components/PrefixedInput.svelte | 138 +++ .../git_sync/GitSyncContext.svelte.ts | 2 +- .../git_sync/GitSyncRepositoryCard.svelte | 1 - .../components/sidebar/SidebarContent.svelte | 41 +- .../components/sidebar/WorkspaceMenu.svelte | 134 ++- .../workspaceSettings/CreateWorkspace.svelte | 427 ++++++++ .../GitSyncFilterSettings.svelte | 2 +- frontend/src/lib/hubPaths.json | 6 +- frontend/src/lib/stores.ts | 7 +- frontend/src/lib/utils/workspaceHierarchy.ts | 129 +++ .../user/(user)/create_workspace/+page.svelte | 350 +------ .../user/(user)/fork_workspace/+page.svelte | 5 + .../user/(user)/workspaces/+page.svelte | 137 ++- 62 files changed, 3506 insertions(+), 436 deletions(-) rename backend/.sqlx/{query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json => query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json} (70%) create mode 100644 backend/.sqlx/query-0924c79aca648e5ec3fcc5e91ca71d524fe9d4b46c2e8ed36ae99b5810a896ab.json create mode 100644 backend/.sqlx/query-0ae9160591ae00117d20a616cfe07e38f0c32953c7e881e916c389255190b72d.json create mode 100644 backend/.sqlx/query-0c7517fba8a6fb4c4e33b1a635cfefa362cdaf79d4f4a32b6d929701b68f4d1c.json create mode 100644 backend/.sqlx/query-0f013d0698d26526ef4ba4f767624263d87163a93a999c0389400dea6c9ec6f6.json create mode 100644 backend/.sqlx/query-1072a02c7765d70c2dce17cd00a0490e61504484b560865006700030dadfbbcf.json create mode 100644 backend/.sqlx/query-26829b40c9cbdc466154dbb9cea3c2a6a1378d87c0e2f0b5c9cda882b52e3eb0.json create mode 100644 backend/.sqlx/query-28e81c9e4a9d166f38fff7e7f1cf87437ac0e47f59eb9d15ad07856a690319ce.json rename backend/.sqlx/{query-3651ed42be75d41ab0387f1551012d72c90235bb40d2f66e6fb235c990d78352.json => query-2aaad552554cc0f1a14394d43ff3bfe4f063ef7615ac6b18384b345046bb8f4b.json} (66%) create mode 100644 backend/.sqlx/query-31869c5dba5cefd4ffaae7720617a40ef42ca940ca4ff7f9fb2e69e63d830d27.json rename backend/.sqlx/{query-1b31847d6187d6969deac5aa7b2feb169ef963449ac2d3ea06e1ed785f6d42e7.json => query-5673ac5b3a5f05aa84bc9247d3bb589a19cbf7f9a7fb14430d82bb0658934b7a.json} (51%) create mode 100644 backend/.sqlx/query-5906c48b3b60d3d770ebc172173f02dfbb892db94f464868dda38a0c6fe98bf6.json create mode 100644 backend/.sqlx/query-5a9deb187b43fde22c4d32629bc030d2fe256d647a0422ba40dfe15a513dc04d.json create mode 100644 backend/.sqlx/query-61d35a8faec1a85f427258a652ebcc7a07689c499e9f7be05d8b25e38671916d.json create mode 100644 backend/.sqlx/query-681be1486a7853c42b1a74aa523a6a1cd42a79852952115e6f30db0dc6ee4b6e.json create mode 100644 backend/.sqlx/query-7d271e97c06f8ac2f61c4d5b7ac6be271b0edf1c41b0cebee58440814bfbe2f1.json create mode 100644 backend/.sqlx/query-841aaef7303e5994d97b529d256e7980b81861af63ba116630603087196ce02b.json create mode 100644 backend/.sqlx/query-93fc5c228ec6d32594b32c5f56bfb3f93779c528c235dda83b68beea71463f8e.json create mode 100644 backend/.sqlx/query-97547c49f4ed07e7d07ddf2ef971abb820df9d6ec54a340cf8b1332e1052f666.json create mode 100644 backend/.sqlx/query-9e096e998ceb820732bd5f5202574de478b1416eec70c1579bd782b36ca65206.json create mode 100644 backend/.sqlx/query-a202f4a0a3c2d56162a13e3593fe950d0e8ae30d7873dd4a55da6a5aa58c2aba.json create mode 100644 backend/.sqlx/query-a517482c6b4ada5022598ce5324c7fe0a959446ed58a6033e0016fbf509ca934.json create mode 100644 backend/.sqlx/query-a6d1b80e1b407610987c98521f8e36dc8e96a63c4690721ae0bc169a3d83aff1.json create mode 100644 backend/.sqlx/query-b7ba64475398162ba43001a9932ac9a2b9ce8295e4ac85421091ab7da17a9a7c.json create mode 100644 backend/.sqlx/query-c920a86a8a00231da69e6ef7c0a7a203e1d7aec995ad5385f99e18a5e200d866.json create mode 100644 backend/.sqlx/query-cd399a3a797d1733fb9071ebca3f5928a3c7eba2983431844581fd2393312a2e.json rename backend/.sqlx/{query-b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113.json => query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json} (51%) create mode 100644 backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json rename backend/.sqlx/{query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json => query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json} (56%) create mode 100644 backend/.sqlx/query-e0d7c895b51ea45a9dd04f79674187299c0b3f68373cf69e2fce08fdeaf185aa.json create mode 100644 backend/.sqlx/query-e628e560caad2e6f37459a255cc931837dd6fb319577ba9d6103fb88b807bfca.json create mode 100644 backend/.sqlx/query-e734447a2506c7d69e744f8ddf1bbc3fad75c097ed483adba1f2f7f593481807.json create mode 100644 backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json create mode 100644 backend/.sqlx/query-f7ae3892df679dbcaca38389132da00b767c58f31cb711acb738733a89765234.json create mode 100644 backend/.sqlx/query-f95e5a80952ba84aa47def1a2f217e2966e7a90554739d7554db5706ba355a3d.json create mode 100644 backend/migrations/20250826120000_add_parent_to_workspace.down.sql create mode 100644 backend/migrations/20250826120000_add_parent_to_workspace.up.sql create mode 100644 cli/src/commands/workspace/fork.ts create mode 100644 frontend/src/lib/components/PrefixedInput.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte create mode 100644 frontend/src/lib/utils/workspaceHierarchy.ts create mode 100644 frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte diff --git a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json similarity index 70% rename from backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json rename to backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index 649ab85650..e7ed0aee65 100644 --- a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n workspace.id AS \"id!\",\n workspace.name AS \"name!\",\n workspace.owner AS \"owner!\",\n workspace.deleted AS \"deleted!\",\n workspace.premium AS \"premium!\",\n workspace_settings.color AS \"color\"\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n LIMIT $1 OFFSET $2", + "query": "SELECT\n workspace.id AS \"id!\",\n workspace.name AS \"name!\",\n workspace.owner AS \"owner!\",\n workspace.deleted AS \"deleted!\",\n workspace.premium AS \"premium!\",\n workspace_settings.color AS \"color\",\n workspace.parent_workspace_id AS \"parent_workspace_id\"\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "color", "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parent_workspace_id", + "type_info": "Varchar" } ], "parameters": { @@ -46,8 +51,9 @@ false, false, false, + true, true ] }, - "hash": "fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74" + "hash": "07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384" } diff --git a/backend/.sqlx/query-0924c79aca648e5ec3fcc5e91ca71d524fe9d4b46c2e8ed36ae99b5810a896ab.json b/backend/.sqlx/query-0924c79aca648e5ec3fcc5e91ca71d524fe9d4b46c2e8ed36ae99b5810a896ab.json new file mode 100644 index 0000000000..f6b2359696 --- /dev/null +++ b/backend/.sqlx/query-0924c79aca648e5ec3fcc5e91ca71d524fe9d4b46c2e8ed36ae99b5810a896ab.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Json", + "Varchar", + "Timestamptz", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "0924c79aca648e5ec3fcc5e91ca71d524fe9d4b46c2e8ed36ae99b5810a896ab" +} diff --git a/backend/.sqlx/query-0ae9160591ae00117d20a616cfe07e38f0c32953c7e881e916c389255190b72d.json b/backend/.sqlx/query-0ae9160591ae00117d20a616cfe07e38f0c32953c7e881e916c389255190b72d.json new file mode 100644 index 0000000000..aae4302601 --- /dev/null +++ b/backend/.sqlx/query-0ae9160591ae00117d20a616cfe07e38f0c32953c7e881e916c389255190b72d.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "0ae9160591ae00117d20a616cfe07e38f0c32953c7e881e916c389255190b72d" +} diff --git a/backend/.sqlx/query-0c7517fba8a6fb4c4e33b1a635cfefa362cdaf79d4f4a32b6d929701b68f4d1c.json b/backend/.sqlx/query-0c7517fba8a6fb4c4e33b1a635cfefa362cdaf79d4f4a32b6d929701b68f4d1c.json new file mode 100644 index 0000000000..de3804a2e7 --- /dev/null +++ b/backend/.sqlx/query-0c7517fba8a6fb4c4e33b1a635cfefa362cdaf79d4f4a32b6d929701b68f4d1c.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path \n FROM app \n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "versions", + "type_info": "Int8Array" + }, + { + "ordinal": 6, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "0c7517fba8a6fb4c4e33b1a635cfefa362cdaf79d4f4a32b6d929701b68f4d1c" +} diff --git a/backend/.sqlx/query-0f013d0698d26526ef4ba4f767624263d87163a93a999c0389400dea6c9ec6f6.json b/backend/.sqlx/query-0f013d0698d26526ef4ba4f767624263d87163a93a999c0389400dea6c9ec6f6.json new file mode 100644 index 0000000000..04207acf25 --- /dev/null +++ b/backend/.sqlx/query-0f013d0698d26526ef4ba4f767624263d87163a93a999c0389400dea6c9ec6f6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow_node (workspace_id, hash, path, lock, code, flow, hash_v2)\n SELECT $2,\n (SELECT COALESCE(MAX(hash), 0) FROM flow_node) + row_number() OVER () AS new_hash,\n source_fn.path, source_fn.lock, source_fn.code, source_fn.flow, source_fn.hash_v2\n FROM flow_node source_fn\n WHERE source_fn.workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "0f013d0698d26526ef4ba4f767624263d87163a93a999c0389400dea6c9ec6f6" +} diff --git a/backend/.sqlx/query-1072a02c7765d70c2dce17cd00a0490e61504484b560865006700030dadfbbcf.json b/backend/.sqlx/query-1072a02c7765d70c2dce17cd00a0490e61504484b560865006700030dadfbbcf.json new file mode 100644 index 0000000000..fb17558432 --- /dev/null +++ b/backend/.sqlx/query-1072a02c7765d70c2dce17cd00a0490e61504484b560865006700030dadfbbcf.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow_version (workspace_id, path, value, schema, created_by, created_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Jsonb", + "Json", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1072a02c7765d70c2dce17cd00a0490e61504484b560865006700030dadfbbcf" +} diff --git a/backend/.sqlx/query-26829b40c9cbdc466154dbb9cea3c2a6a1378d87c0e2f0b5c9cda882b52e3eb0.json b/backend/.sqlx/query-26829b40c9cbdc466154dbb9cea3c2a6a1378d87c0e2f0b5c9cda882b52e3eb0.json new file mode 100644 index 0000000000..c2639ca086 --- /dev/null +++ b/backend/.sqlx/query-26829b40c9cbdc466154dbb9cea3c2a6a1378d87c0e2f0b5c9cda882b52e3eb0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)\n SELECT $2, path, value, description, resource_type, extra_perms, edited_at, $3\n FROM resource \n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "26829b40c9cbdc466154dbb9cea3c2a6a1378d87c0e2f0b5c9cda882b52e3eb0" +} diff --git a/backend/.sqlx/query-28e81c9e4a9d166f38fff7e7f1cf87437ac0e47f59eb9d15ad07856a690319ce.json b/backend/.sqlx/query-28e81c9e4a9d166f38fff7e7f1cf87437ac0e47f59eb9d15ad07856a690319ce.json new file mode 100644 index 0000000000..a139571c83 --- /dev/null +++ b/backend/.sqlx/query-28e81c9e4a9d166f38fff7e7f1cf87437ac0e47f59eb9d15ad07856a690319ce.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT flow_path, runnable_path, script_hash, runnable_is_flow, app_path\n FROM workspace_runnable_dependencies \n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flow_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "script_hash", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "runnable_is_flow", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "app_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false, + true, + false, + true + ] + }, + "hash": "28e81c9e4a9d166f38fff7e7f1cf87437ac0e47f59eb9d15ad07856a690319ce" +} diff --git a/backend/.sqlx/query-3651ed42be75d41ab0387f1551012d72c90235bb40d2f66e6fb235c990d78352.json b/backend/.sqlx/query-2aaad552554cc0f1a14394d43ff3bfe4f063ef7615ac6b18384b345046bb8f4b.json similarity index 66% rename from backend/.sqlx/query-3651ed42be75d41ab0387f1551012d72c90235bb40d2f66e6fb235c990d78352.json rename to backend/.sqlx/query-2aaad552554cc0f1a14394d43ff3bfe4f063ef7615ac6b18384b345046bb8f4b.json index 87e705630b..ac1cf5e96b 100644 --- a/backend/.sqlx/query-3651ed42be75d41ab0387f1551012d72c90235bb40d2f66e6fb235c990d78352.json +++ b/backend/.sqlx/query-2aaad552554cc0f1a14394d43ff3bfe4f063ef7615ac6b18384b345046bb8f4b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n JOIN usr ON usr.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "query": "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n JOIN usr ON usr.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "color", "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parent_workspace_id", + "type_info": "Varchar" } ], "parameters": { @@ -45,8 +50,9 @@ false, false, false, + true, true ] }, - "hash": "3651ed42be75d41ab0387f1551012d72c90235bb40d2f66e6fb235c990d78352" + "hash": "2aaad552554cc0f1a14394d43ff3bfe4f063ef7615ac6b18384b345046bb8f4b" } diff --git a/backend/.sqlx/query-31869c5dba5cefd4ffaae7720617a40ef42ca940ca4ff7f9fb2e69e63d830d27.json b/backend/.sqlx/query-31869c5dba5cefd4ffaae7720617a40ef42ca940ca4ff7f9fb2e69e63d830d27.json new file mode 100644 index 0000000000..e1bf471dd5 --- /dev/null +++ b/backend/.sqlx/query-31869c5dba5cefd4ffaae7720617a40ef42ca940ca4ff7f9fb2e69e63d830d27.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at\n FROM variable \n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_secret", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "account", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "is_oauth", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "31869c5dba5cefd4ffaae7720617a40ef42ca940ca4ff7f9fb2e69e63d830d27" +} diff --git a/backend/.sqlx/query-1b31847d6187d6969deac5aa7b2feb169ef963449ac2d3ea06e1ed785f6d42e7.json b/backend/.sqlx/query-5673ac5b3a5f05aa84bc9247d3bb589a19cbf7f9a7fb14430d82bb0658934b7a.json similarity index 51% rename from backend/.sqlx/query-1b31847d6187d6969deac5aa7b2feb169ef963449ac2d3ea06e1ed785f6d42e7.json rename to backend/.sqlx/query-5673ac5b3a5f05aa84bc9247d3bb589a19cbf7f9a7fb14430d82bb0658934b7a.json index 45b9819fae..946d6b0e87 100644 --- a/backend/.sqlx/query-1b31847d6187d6969deac5aa7b2feb169ef963449ac2d3ea06e1ed785f6d42e7.json +++ b/backend/.sqlx/query-5673ac5b3a5f05aa84bc9247d3bb589a19cbf7f9a7fb14430d82bb0658934b7a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT * from workspace_invite WHERE email = $1", + "query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "ordinal": 3, "name": "operator", "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "parent_workspace_id", + "type_info": "Varchar" } ], "parameters": { @@ -33,8 +38,9 @@ false, false, false, - false + false, + true ] }, - "hash": "1b31847d6187d6969deac5aa7b2feb169ef963449ac2d3ea06e1ed785f6d42e7" + "hash": "5673ac5b3a5f05aa84bc9247d3bb589a19cbf7f9a7fb14430d82bb0658934b7a" } diff --git a/backend/.sqlx/query-5906c48b3b60d3d770ebc172173f02dfbb892db94f464868dda38a0c6fe98bf6.json b/backend/.sqlx/query-5906c48b3b60d3d770ebc172173f02dfbb892db94f464868dda38a0c6fe98bf6.json new file mode 100644 index 0000000000..5388b95d76 --- /dev/null +++ b/backend/.sqlx/query-5906c48b3b60d3d770ebc172173f02dfbb892db94f464868dda38a0c6fe98bf6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_settings\n SET\n deploy_to = $1,\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5906c48b3b60d3d770ebc172173f02dfbb892db94f464868dda38a0c6fe98bf6" +} diff --git a/backend/.sqlx/query-5a9deb187b43fde22c4d32629bc030d2fe256d647a0422ba40dfe15a513dc04d.json b/backend/.sqlx/query-5a9deb187b43fde22c4d32629bc030d2fe256d647a0422ba40dfe15a513dc04d.json new file mode 100644 index 0000000000..7aef978d6e --- /dev/null +++ b/backend/.sqlx/query-5a9deb187b43fde22c4d32629bc030d2fe256d647a0422ba40dfe15a513dc04d.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Text", + "Text", + "Bpchar" + ] + }, + "nullable": [] + }, + "hash": "5a9deb187b43fde22c4d32629bc030d2fe256d647a0422ba40dfe15a513dc04d" +} diff --git a/backend/.sqlx/query-61d35a8faec1a85f427258a652ebcc7a07689c499e9f7be05d8b25e38671916d.json b/backend/.sqlx/query-61d35a8faec1a85f427258a652ebcc7a07689c499e9f7be05d8b25e38671916d.json new file mode 100644 index 0000000000..57da14a473 --- /dev/null +++ b/backend/.sqlx/query-61d35a8faec1a85f427258a652ebcc7a07689c499e9f7be05d8b25e38671916d.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT app_id, value, created_by, created_at, raw_app\n FROM app_version \n WHERE app_id = ANY(SELECT id FROM app WHERE workspace_id = $1)\n ORDER BY app_id, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "app_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "raw_app", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "61d35a8faec1a85f427258a652ebcc7a07689c499e9f7be05d8b25e38671916d" +} diff --git a/backend/.sqlx/query-681be1486a7853c42b1a74aa523a6a1cd42a79852952115e6f30db0dc6ee4b6e.json b/backend/.sqlx/query-681be1486a7853c42b1a74aa523a6a1cd42a79852952115e6f30db0dc6ee4b6e.json new file mode 100644 index 0000000000..01693cc7c5 --- /dev/null +++ b/backend/.sqlx/query-681be1486a7853c42b1a74aa523a6a1cd42a79852952115e6f30db0dc6ee4b6e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, draft_only, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, $3, edited_at,\n archived, schema, extra_perms, NULL, draft_only, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow \n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "681be1486a7853c42b1a74aa523a6a1cd42a79852952115e6f30db0dc6ee4b6e" +} diff --git a/backend/.sqlx/query-7d271e97c06f8ac2f61c4d5b7ac6be271b0edf1c41b0cebee58440814bfbe2f1.json b/backend/.sqlx/query-7d271e97c06f8ac2f61c4d5b7ac6be271b0edf1c41b0cebee58440814bfbe2f1.json new file mode 100644 index 0000000000..0e3683ab51 --- /dev/null +++ b/backend/.sqlx/query-7d271e97c06f8ac2f61c4d5b7ac6be271b0edf1c41b0cebee58440814bfbe2f1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_env (workspace_id, name, value)\n SELECT $2, name, value\n FROM workspace_env\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "7d271e97c06f8ac2f61c4d5b7ac6be271b0edf1c41b0cebee58440814bfbe2f1" +} diff --git a/backend/.sqlx/query-841aaef7303e5994d97b529d256e7980b81861af63ba116630603087196ce02b.json b/backend/.sqlx/query-841aaef7303e5994d97b529d256e7980b81861af63ba116630603087196ce02b.json new file mode 100644 index 0000000000..b95af1ddc8 --- /dev/null +++ b/backend/.sqlx/query-841aaef7303e5994d97b529d256e7980b81861af63ba116630603087196ce02b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension)\n SELECT $2, name, schema, description, edited_at, $3, format_extension\n FROM resource_type \n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "841aaef7303e5994d97b529d256e7980b81861af63ba116630603087196ce02b" +} diff --git a/backend/.sqlx/query-93fc5c228ec6d32594b32c5f56bfb3f93779c528c235dda83b68beea71463f8e.json b/backend/.sqlx/query-93fc5c228ec6d32594b32c5f56bfb3f93779c528c235dda83b68beea71463f8e.json new file mode 100644 index 0000000000..793d0995aa --- /dev/null +++ b/backend/.sqlx/query-93fc5c228ec6d32594b32c5f56bfb3f93779c528c235dda83b68beea71463f8e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT owner FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "93fc5c228ec6d32594b32c5f56bfb3f93779c528c235dda83b68beea71463f8e" +} diff --git a/backend/.sqlx/query-97547c49f4ed07e7d07ddf2ef971abb820df9d6ec54a340cf8b1332e1052f666.json b/backend/.sqlx/query-97547c49f4ed07e7d07ddf2ef971abb820df9d6ec54a340cf8b1332e1052f666.json new file mode 100644 index 0000000000..0a9fd45382 --- /dev/null +++ b/backend/.sqlx/query-97547c49f4ed07e7d07ddf2ef971abb820df9d6ec54a340cf8b1332e1052f666.json @@ -0,0 +1,271 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT hash, path, summary, description, content,\n created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language as \"language: ScriptLang\", \n kind as \"kind: ScriptKind\", tag, draft_only, envs, concurrent_limit, \n concurrency_time_window_s, cache_ttl, dedicated_worker, \n ws_error_handler_muted, priority, timeout, delete_after_use, \n restart_unless_cancelled, concurrency_key, visible_to_runner_only,\n no_main_func, codebase, has_preprocessor, on_behalf_of_email,\n parent_hashes, assets\n FROM script WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "archived", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "schema", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_template", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "lock", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "lock_error_logs", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "language: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + } + }, + { + "ordinal": 14, + "name": "kind: ScriptKind", + "type_info": { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + } + }, + { + "ordinal": 15, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "envs", + "type_info": "VarcharArray" + }, + { + "ordinal": 18, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 19, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "dedicated_worker", + "type_info": "Bool" + }, + { + "ordinal": 22, + "name": "ws_error_handler_muted", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "delete_after_use", + "type_info": "Bool" + }, + { + "ordinal": 26, + "name": "restart_unless_cancelled", + "type_info": "Bool" + }, + { + "ordinal": 27, + "name": "concurrency_key", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "visible_to_runner_only", + "type_info": "Bool" + }, + { + "ordinal": 29, + "name": "no_main_func", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "codebase", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "has_preprocessor", + "type_info": "Bool" + }, + { + "ordinal": 32, + "name": "on_behalf_of_email", + "type_info": "Text" + }, + { + "ordinal": 33, + "name": "parent_hashes", + "type_info": "Int8Array" + }, + { + "ordinal": 34, + "name": "assets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "97547c49f4ed07e7d07ddf2ef971abb820df9d6ec54a340cf8b1332e1052f666" +} diff --git a/backend/.sqlx/query-9e096e998ceb820732bd5f5202574de478b1416eec70c1579bd782b36ca65206.json b/backend/.sqlx/query-9e096e998ceb820732bd5f5202574de478b1416eec70c1579bd782b36ca65206.json new file mode 100644 index 0000000000..93974f5889 --- /dev/null +++ b/backend/.sqlx/query-9e096e998ceb820732bd5f5202574de478b1416eec70c1579bd782b36ca65206.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_ (workspace_id, name, summary, extra_perms)\n SELECT $2, name, summary, extra_perms\n FROM group_\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "9e096e998ceb820732bd5f5202574de478b1416eec70c1579bd782b36ca65206" +} diff --git a/backend/.sqlx/query-a202f4a0a3c2d56162a13e3593fe950d0e8ae30d7873dd4a55da6a5aa58c2aba.json b/backend/.sqlx/query-a202f4a0a3c2d56162a13e3593fe950d0e8ae30d7873dd4a55da6a5aa58c2aba.json new file mode 100644 index 0000000000..adb56075fe --- /dev/null +++ b/backend/.sqlx/query-a202f4a0a3c2d56162a13e3593fe950d0e8ae30d7873dd4a55da6a5aa58c2aba.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO raw_app (path, version, workspace_id, summary, edited_at, data, extra_perms)\n SELECT path, version, $2, summary, edited_at, data, extra_perms\n FROM raw_app \n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "a202f4a0a3c2d56162a13e3593fe950d0e8ae30d7873dd4a55da6a5aa58c2aba" +} diff --git a/backend/.sqlx/query-a517482c6b4ada5022598ce5324c7fe0a959446ed58a6033e0016fbf509ca934.json b/backend/.sqlx/query-a517482c6b4ada5022598ce5324c7fe0a959446ed58a6033e0016fbf509ca934.json new file mode 100644 index 0000000000..d7cde6e855 --- /dev/null +++ b/backend/.sqlx/query-a517482c6b4ada5022598ce5324c7fe0a959446ed58a6033e0016fbf509ca934.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_workspace_id FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "a517482c6b4ada5022598ce5324c7fe0a959446ed58a6033e0016fbf509ca934" +} diff --git a/backend/.sqlx/query-a6d1b80e1b407610987c98521f8e36dc8e96a63c4690721ae0bc169a3d83aff1.json b/backend/.sqlx/query-a6d1b80e1b407610987c98521f8e36dc8e96a63c4690721ae0bc169a3d83aff1.json new file mode 100644 index 0000000000..63301d439c --- /dev/null +++ b/backend/.sqlx/query-a6d1b80e1b407610987c98521f8e36dc8e96a63c4690721ae0bc169a3d83aff1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Jsonb", + "Int4", + "Bool", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a6d1b80e1b407610987c98521f8e36dc8e96a63c4690721ae0bc169a3d83aff1" +} diff --git a/backend/.sqlx/query-b7ba64475398162ba43001a9932ac9a2b9ce8295e4ac85421091ab7da17a9a7c.json b/backend/.sqlx/query-b7ba64475398162ba43001a9932ac9a2b9ce8295e4ac85421091ab7da17a9a7c.json new file mode 100644 index 0000000000..1ccac6145e --- /dev/null +++ b/backend/.sqlx/query-b7ba64475398162ba43001a9932ac9a2b9ce8295e4ac85421091ab7da17a9a7c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by)\n SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, $3\n FROM folder\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b7ba64475398162ba43001a9932ac9a2b9ce8295e4ac85421091ab7da17a9a7c" +} diff --git a/backend/.sqlx/query-c920a86a8a00231da69e6ef7c0a7a203e1d7aec995ad5385f99e18a5e200d866.json b/backend/.sqlx/query-c920a86a8a00231da69e6ef7c0a7a203e1d7aec995ad5385f99e18a5e200d866.json new file mode 100644 index 0000000000..6b690de1eb --- /dev/null +++ b/backend/.sqlx/query-c920a86a8a00231da69e6ef7c0a7a203e1d7aec995ad5385f99e18a5e200d866.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, path, value, schema, created_by, created_at \n FROM flow_version \n WHERE workspace_id = $1 \n ORDER BY path, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "value", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "schema", + "type_info": "Json" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "c920a86a8a00231da69e6ef7c0a7a203e1d7aec995ad5385f99e18a5e200d866" +} diff --git a/backend/.sqlx/query-cd399a3a797d1733fb9071ebca3f5928a3c7eba2983431844581fd2393312a2e.json b/backend/.sqlx/query-cd399a3a797d1733fb9071ebca3f5928a3c7eba2983431844581fd2393312a2e.json new file mode 100644 index 0000000000..0b33e98f7c --- /dev/null +++ b/backend/.sqlx/query-cd399a3a797d1733fb9071ebca3f5928a3c7eba2983431844581fd2393312a2e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator)\n SELECT $1, email, is_admin, operator\n FROM usr\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cd399a3a797d1733fb9071ebca3f5928a3c7eba2983431844581fd2393312a2e" +} diff --git a/backend/.sqlx/query-b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113.json b/backend/.sqlx/query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json similarity index 51% rename from backend/.sqlx/query-b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113.json rename to backend/.sqlx/query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json index a8bd3c0e8f..f2b40e0184 100644 --- a/backend/.sqlx/query-b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113.json +++ b/backend/.sqlx/query-ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT * from workspace_invite WHERE workspace_id = $1", + "query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id\n WHERE workspace_id = $1", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "ordinal": 3, "name": "operator", "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "parent_workspace_id", + "type_info": "Varchar" } ], "parameters": { @@ -33,8 +38,9 @@ false, false, false, - false + false, + true ] }, - "hash": "b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113" + "hash": "ce5c081bbf8322371a6ec95b6a0b033837d574a1ec72dfe42b666172daf10880" } diff --git a/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json b/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json new file mode 100644 index 0000000000..425b4d8502 --- /dev/null +++ b/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Int8Array", + "Jsonb", + "Bool", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5" +} diff --git a/backend/.sqlx/query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json b/backend/.sqlx/query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json similarity index 56% rename from backend/.sqlx/query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json rename to backend/.sqlx/query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json index 1bad8b94d0..d284956ca1 100644 --- a/backend/.sqlx/query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json +++ b/backend/.sqlx/query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings\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 CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings\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": [ { @@ -25,6 +25,11 @@ }, { "ordinal": 4, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "operator_settings", "type_info": "Jsonb" } @@ -39,8 +44,9 @@ false, false, true, + true, null ] }, - "hash": "1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14" + "hash": "d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77" } diff --git a/backend/.sqlx/query-e0d7c895b51ea45a9dd04f79674187299c0b3f68373cf69e2fce08fdeaf185aa.json b/backend/.sqlx/query-e0d7c895b51ea45a9dd04f79674187299c0b3f68373cf69e2fce08fdeaf185aa.json new file mode 100644 index 0000000000..fd0a21cd69 --- /dev/null +++ b/backend/.sqlx/query-e0d7c895b51ea45a9dd04f79674187299c0b3f68373cf69e2fce08fdeaf185aa.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT app, hash, lock, code, code_sha256 \n FROM app_script \n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "app", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 2, + "name": "lock", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "code_sha256", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "e0d7c895b51ea45a9dd04f79674187299c0b3f68373cf69e2fce08fdeaf185aa" +} diff --git a/backend/.sqlx/query-e628e560caad2e6f37459a255cc931837dd6fb319577ba9d6103fb88b807bfca.json b/backend/.sqlx/query-e628e560caad2e6f37459a255cc931837dd6fb319577ba9d6103fb88b807bfca.json new file mode 100644 index 0000000000..b75aaa7383 --- /dev/null +++ b/backend/.sqlx/query-e628e560caad2e6f37459a255cc931837dd6fb319577ba9d6103fb88b807bfca.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_runnable_dependencies (\n flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path\n ) VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Bool", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "e628e560caad2e6f37459a255cc931837dd6fb319577ba9d6103fb88b807bfca" +} diff --git a/backend/.sqlx/query-e734447a2506c7d69e744f8ddf1bbc3fad75c097ed483adba1f2f7f593481807.json b/backend/.sqlx/query-e734447a2506c7d69e744f8ddf1bbc3fad75c097ed483adba1f2f7f593481807.json new file mode 100644 index 0000000000..3d0c982141 --- /dev/null +++ b/backend/.sqlx/query-e734447a2506c7d69e744f8ddf1bbc3fad75c097ed483adba1f2f7f593481807.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, no_main_func, codebase, has_preprocessor,\n on_behalf_of_email, assets\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35,\n $36, $37\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Timestamptz", + "Bool", + "Json", + "Bool", + "Bool", + "Jsonb", + "Text", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "Bool", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Int4", + "Bool", + "Bool", + "Varchar", + "Bool", + "Bool", + "Varchar", + "Bool", + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "e734447a2506c7d69e744f8ddf1bbc3fad75c097ed483adba1f2f7f593481807" +} diff --git a/backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json b/backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json new file mode 100644 index 0000000000..4fe298370b --- /dev/null +++ b/backend/.sqlx/query-e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c" +} diff --git a/backend/.sqlx/query-f7ae3892df679dbcaca38389132da00b767c58f31cb711acb738733a89765234.json b/backend/.sqlx/query-f7ae3892df679dbcaca38389132da00b767c58f31cb711acb738733a89765234.json new file mode 100644 index 0000000000..1b0e5f9e7d --- /dev/null +++ b/backend/.sqlx/query-f7ae3892df679dbcaca38389132da00b767c58f31cb711acb738733a89765234.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE flow\n SET versions = array_append(versions, $1)\n WHERE workspace_id = $2 AND path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f7ae3892df679dbcaca38389132da00b767c58f31cb711acb738733a89765234" +} diff --git a/backend/.sqlx/query-f95e5a80952ba84aa47def1a2f217e2966e7a90554739d7554db5706ba355a3d.json b/backend/.sqlx/query-f95e5a80952ba84aa47def1a2f217e2966e7a90554739d7554db5706ba355a3d.json new file mode 100644 index 0000000000..fc0853410f --- /dev/null +++ b/backend/.sqlx/query-f95e5a80952ba84aa47def1a2f217e2966e7a90554739d7554db5706ba355a3d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET versions = (\n SELECT array_agg(av.id ORDER BY av.created_at)\n FROM app_version av \n WHERE av.app_id = app.id\n ) WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f95e5a80952ba84aa47def1a2f217e2966e7a90554739d7554db5706ba355a3d" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4477b27ed7..484330c017 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -8f539adc1b5331de3c2cd539712526d653bfbabf \ No newline at end of file +254a6b563c503fb09d2dc332de70c2173b96491c diff --git a/backend/migrations/20250826120000_add_parent_to_workspace.down.sql b/backend/migrations/20250826120000_add_parent_to_workspace.down.sql new file mode 100644 index 0000000000..9a2c443efc --- /dev/null +++ b/backend/migrations/20250826120000_add_parent_to_workspace.down.sql @@ -0,0 +1,7 @@ +-- Rollback: Remove parent_workspace_id and created_by columns from workspace table + +-- Drop the index first +DROP INDEX IF EXISTS workspace_parent_idx; + +-- Remove the columns +ALTER TABLE workspace DROP COLUMN IF EXISTS parent_workspace_id; diff --git a/backend/migrations/20250826120000_add_parent_to_workspace.up.sql b/backend/migrations/20250826120000_add_parent_to_workspace.up.sql new file mode 100644 index 0000000000..f3ecb25300 --- /dev/null +++ b/backend/migrations/20250826120000_add_parent_to_workspace.up.sql @@ -0,0 +1,8 @@ +-- Add parent_workspace_id and created_by columns to workspace table + +-- Add parent_workspace_id column (optional, references workspace.id) +ALTER TABLE workspace +ADD COLUMN parent_workspace_id character varying(50) REFERENCES workspace(id) ON DELETE SET NULL; + +-- Create index for performance on parent_workspace_id lookups +CREATE INDEX workspace_parent_idx ON workspace(parent_workspace_id) WHERE parent_workspace_id IS NOT NULL; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8c69ff50b5..9f16dfd8e7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -674,6 +674,27 @@ paths: schema: type: string + /workspaces/create_fork: + post: + summary: create forked workspace + operationId: createWorkspaceFork + tags: + - workspace + requestBody: + description: new forked workspace + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateWorkspaceFork" + responses: + "201": + description: forked workspace created + content: + text/plain: + schema: + type: string + /workspaces/exists: post: summary: exists workspace @@ -1561,6 +1582,9 @@ paths: type: boolean operator: type: boolean + parent_workspace_id: + type: string + nullable: true required: - email - is_admin @@ -17488,6 +17512,12 @@ components: type: string operator_settings: $ref: "#/components/schemas/OperatorSettings" + parent_workspace_id: + type: string + nullable: true + created_by: + type: string + nullable: true required: - id - name @@ -17512,6 +17542,24 @@ components: - id - name + CreateWorkspaceFork: + type: object + properties: + id: + type: string + name: + type: string + username: + type: string + color: + type: string + parent_workspace_id: + type: string + required: + - id + - name + - parent_workspace_id + Workspace: type: object properties: @@ -17525,10 +17573,14 @@ components: type: string color: type: string + parent_workspace_id: + type: string + nullable: true required: - id - name - owner + - created_at WorkspaceInvite: type: object @@ -17541,6 +17593,9 @@ components: type: boolean operator: type: boolean + parent_workspace_id: + type: string + nullable: true required: - workspace_id - email diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 825c26f682..6ded25f923 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -311,6 +311,7 @@ pub struct WorkspaceInvite { pub email: String, pub is_admin: bool, pub operator: bool, + pub parent_workspace_id: Option, } #[allow(dead_code)] @@ -616,7 +617,13 @@ async fn list_invites( let mut tx = db.begin().await?; let rows = sqlx::query_as!( WorkspaceInvite, - "SELECT * from workspace_invite WHERE email = $1", + "SELECT + workspace_invite.workspace_id, + workspace_invite.email, + workspace_invite.is_admin, + workspace_invite.operator, + workspace.parent_workspace_id + FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1", authed.email ) .fetch_all(&mut *tx) diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 037f67c995..9b51daaddf 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -6,8 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -use std::collections::HashMap; - use crate::ai::{AIConfig, AI_REQUEST_CACHE}; use crate::db::ApiAuthed; use crate::users_oss::send_email_if_possible; @@ -29,12 +27,18 @@ use chrono::Utc; use regex::Regex; +use hex; +use sha2::{Digest, Sha256}; +use std::collections::{hash_map::DefaultHasher, HashMap}; +use std::hash::{Hash, Hasher}; use uuid::Uuid; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::s3_helpers::LargeFileStorage; +use windmill_common::scripts::{NewScript, ScriptKind, ScriptLang}; use windmill_common::users::username_to_permissioned_as; +use windmill_common::variables::ExportableListableVariable; use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE}; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; #[cfg(feature = "enterprise")] @@ -172,6 +176,7 @@ pub fn global_service() -> Router { .route("/list", get(list_workspaces)) .route("/users", get(user_workspaces)) .route("/create", post(create_workspace)) + .route("/create_fork", post(create_workspace_fork)) .route("/exists", post(exists_workspace)) .route("/exists_username", post(exists_username)) .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) @@ -194,6 +199,7 @@ struct Workspace { deleted: bool, premium: bool, color: Option, + parent_workspace_id: Option, } #[derive(FromRow, Serialize, Debug)] @@ -332,6 +338,15 @@ struct CreateWorkspace { color: Option, } +#[derive(Deserialize)] +struct CreateWorkspaceFork { + id: String, + name: String, + username: Option, + color: Option, + parent_workspace_id: String, +} + #[derive(Deserialize)] struct EditWorkspace { name: String, @@ -351,6 +366,7 @@ struct UserWorkspace { pub username: String, pub color: Option, pub operator_settings: Option>, + pub parent_workspace_id: Option, } #[derive(Deserialize)] @@ -399,7 +415,14 @@ async fn list_pending_invites( let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as!( WorkspaceInvite, - "SELECT * from workspace_invite WHERE workspace_id = $1", + "SELECT + workspace_invite.workspace_id, + workspace_invite.email, + workspace_invite.is_admin, + workspace_invite.operator, + workspace.parent_workspace_id + FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id + WHERE workspace_id = $1", w_id ) .fetch_all(&mut *tx) @@ -447,7 +470,7 @@ async fn list_workspaces( let mut tx = user_db.begin(&authed).await?; let workspaces = sqlx::query_as!( Workspace, - "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color + "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id FROM workspace LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id JOIN usr ON usr.workspace_id = workspace.id @@ -720,6 +743,7 @@ async fn edit_deploy_to() -> Result { } pub const BANNED_DOMAINS: &str = include_str!("../banned_domains.txt"); +pub const WM_FORK_PREFIX: &str = "wm-fork-"; pub const MAX_CUSTOM_PROMPT_LENGTH: usize = 5000; async fn is_allowed_auto_domain(ApiAuthed { email, .. }: ApiAuthed) -> JsonResult { @@ -2063,7 +2087,8 @@ async fn list_workspaces_as_super_admin( workspace.owner AS \"owner!\", workspace.deleted AS \"deleted!\", workspace.premium AS \"premium!\", - workspace_settings.color AS \"color\" + workspace_settings.color AS \"color\", + workspace.parent_workspace_id AS \"parent_workspace_id\" FROM workspace LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id LIMIT $1 OFFSET $2", @@ -2083,7 +2108,7 @@ async fn user_workspaces( let mut tx = db.begin().await?; let workspaces = sqlx::query_as!( UserWorkspace, - "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, + "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings FROM workspace JOIN usr ON usr.workspace_id = workspace.id @@ -2316,6 +2341,884 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } +fn hash_script(ns: &NewScript) -> i64 { + let mut dh = DefaultHasher::new(); + ns.hash(&mut dh); + dh.finish() as i64 +} + +async fn clone_workspace_data( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, + db: &DB, +) -> Result<()> { + // Clone workspace settings (merge with existing basic settings) + update_workspace_settings( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone workspace environment variables + clone_workspace_env(tx, source_workspace_id, target_workspace_id).await?; + + // Clone folders + clone_folders( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone groups + clone_groups(tx, source_workspace_id, target_workspace_id).await?; + + // Clone resource types + clone_resource_types( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone resources + clone_resources( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone variables with re-encryption + clone_variables(tx, source_workspace_id, target_workspace_id, db).await?; + + // Clone scripts with new hashes + let script_hash_mapping = clone_scripts( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone flows with new versions + clone_flows( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone flow nodes + clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; + + // Clone apps with new IDs and app scripts + let _app_id_mapping = clone_apps( + tx, + source_workspace_id, + target_workspace_id, + target_username, + ) + .await?; + + // Clone raw apps + clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + + // Clone workspace runnable dependencies with updated mappings + clone_workspace_dependencies( + tx, + source_workspace_id, + target_workspace_id, + &script_hash_mapping, + ) + .await?; + + Ok(()) +} + +async fn update_workspace_settings( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + _target_username: &str, +) -> Result<()> { + sqlx::query!( + r#" + UPDATE workspace_settings + SET + deploy_to = $1, + ai_config = source_ws.ai_config, + large_file_storage = source_ws.large_file_storage, + git_app_installations = source_ws.git_app_installations + FROM workspace_settings source_ws + WHERE source_ws.workspace_id = $1 + AND workspace_settings.workspace_id = $2 + "#, + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + let current_git_sync_settings = sqlx::query!( + "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", + source_workspace_id + ) + .fetch_optional(&mut **tx) + .await?; + + let mut git_sync_settings = if let Some(row) = current_git_sync_settings { + if let Some(git_sync) = row.git_sync { + serde_json::from_value::(git_sync) + .map_err(|err| Error::internal_err(err.to_string()))? + } else { + WorkspaceGitSyncSettings::default() + } + } else { + WorkspaceGitSyncSettings::default() + }; + + // We only keep the first git sync repo, since it is considered the main one + // Context: see WIN-1559 + git_sync_settings.repositories.truncate(1); + + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + target_workspace_id + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_workspace_env( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO workspace_env (workspace_id, name, value) + SELECT $2, name, value + FROM workspace_env + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_folders( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by) + SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, $3 + FROM folder + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + target_username, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_groups( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) + SELECT $2, name, summary, extra_perms + FROM group_ + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_resource_types( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension) + SELECT $2, name, schema, description, edited_at, $3, format_extension + FROM resource_type + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + target_username, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_resources( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by) + SELECT $2, path, value, description, resource_type, extra_perms, edited_at, $3 + FROM resource + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + target_username, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_variables( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + db: &DB, +) -> Result<()> { + // Get all variables from source workspace + let variables = sqlx::query_as!( + ExportableListableVariable, + "SELECT workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at + FROM variable + WHERE workspace_id = $1", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + if variables.is_empty() { + return Ok(()); + } + + // Get workspace keys from within the transaction + let source_key = sqlx::query_scalar!( + "SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'", + source_workspace_id + ) + .fetch_one(db) + .await?; + + let target_key = sqlx::query_scalar!( + "SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'", + target_workspace_id + ) + .fetch_one(&mut **tx) + .await?; + + // Build encryption keys manually + use windmill_common::variables::SECRET_SALT; + let source_crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { + format!("{}{}", source_key, salt) + } else { + source_key + }; + let target_crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { + format!("{}{}", target_key, salt) + } else { + target_key + }; + + let source_mc = magic_crypt::new_magic_crypt!(source_crypt_key, 256); + let target_mc = magic_crypt::new_magic_crypt!(target_crypt_key, 256); + + // Process each variable + for var in variables { + let final_value = if var.is_secret && var.value.is_some() { + // Decrypt with source key and re-encrypt with target key + let decrypted_value = decrypt(&source_mc, var.value.unwrap())?; + Some(encrypt(&target_mc, &decrypted_value)) + } else { + var.value + }; + + sqlx::query!( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + target_workspace_id, + var.path, + final_value, + var.is_secret, + var.description, + var.extra_perms, + var.account, + var.is_oauth, + var.expires_at, + ) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +async fn clone_scripts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, +) -> Result> { + // Get all scripts from source workspace + let scripts = sqlx::query!( + r#"SELECT hash, path, summary, description, content, + created_at, archived, schema, deleted, is_template, + extra_perms, lock, lock_error_logs, language as "language: ScriptLang", + kind as "kind: ScriptKind", tag, draft_only, envs, concurrent_limit, + concurrency_time_window_s, cache_ttl, dedicated_worker, + ws_error_handler_muted, priority, timeout, delete_after_use, + restart_unless_cancelled, concurrency_key, visible_to_runner_only, + no_main_func, codebase, has_preprocessor, on_behalf_of_email, + parent_hashes, assets + FROM script WHERE workspace_id = $1"#, + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + let mut script_hash_mapping: HashMap = HashMap::new(); + + // Process each script with new hash computation + for script in scripts { + // Create a duplicate of ScriptKind by matching the enum + let script_kind_for_hash = match script.kind { + ScriptKind::Script => ScriptKind::Script, + ScriptKind::Trigger => ScriptKind::Trigger, + ScriptKind::Failure => ScriptKind::Failure, + ScriptKind::Approval => ScriptKind::Approval, + ScriptKind::Preprocessor => ScriptKind::Preprocessor, + }; + let script_kind_for_db = match script.kind { + ScriptKind::Script => ScriptKind::Script, + ScriptKind::Trigger => ScriptKind::Trigger, + ScriptKind::Failure => ScriptKind::Failure, + ScriptKind::Approval => ScriptKind::Approval, + ScriptKind::Preprocessor => ScriptKind::Preprocessor, + }; + + // Create NewScript for hash computation - simplified approach + let new_script = NewScript { + path: script.path.clone(), + parent_hash: None, + summary: script.summary.clone(), + description: script.description.clone(), + content: script.content.clone(), + schema: None, // Keep it simple for hash computation + is_template: Some(script.is_template.unwrap_or(false)), + lock: script.lock.clone(), + language: script.language.clone(), + kind: Some(script_kind_for_hash), + tag: script.tag.clone(), + draft_only: script.draft_only, + envs: script.envs.clone(), + concurrent_limit: script.concurrent_limit, + concurrency_time_window_s: script.concurrency_time_window_s, + cache_ttl: script.cache_ttl, + dedicated_worker: script.dedicated_worker, + ws_error_handler_muted: Some(script.ws_error_handler_muted), + priority: script.priority, + timeout: script.timeout, + delete_after_use: script.delete_after_use, + restart_unless_cancelled: script.restart_unless_cancelled, + deployment_message: None, + concurrency_key: script.concurrency_key.clone(), + visible_to_runner_only: script.visible_to_runner_only, + no_main_func: script.no_main_func, + codebase: script.codebase.clone(), + has_preprocessor: script.has_preprocessor, + on_behalf_of_email: script.on_behalf_of_email.clone(), + assets: None, + }; + + // Generate new hash + let new_hash = hash_script(&new_script); + + // Store mapping for later reference updates + script_hash_mapping.insert(script.hash, new_hash); + + // Insert script with new hash - direct copy most fields + sqlx::query!( + r#"INSERT INTO script ( + workspace_id, hash, path, parent_hashes, summary, description, content, + created_by, created_at, archived, schema, deleted, is_template, + extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, + dedicated_worker, ws_error_handler_muted, priority, timeout, + delete_after_use, restart_unless_cancelled, concurrency_key, + visible_to_runner_only, no_main_func, codebase, has_preprocessor, + on_behalf_of_email, assets + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, + $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, + $36, $37 + )"#, + target_workspace_id, + new_hash, + script.path, + script.parent_hashes.as_deref(), + script.summary, + script.description, + script.content, + target_username, + script.created_at, + script.archived, + script.schema, + script.deleted, + script.is_template, + script.extra_perms, + script.lock, + script.lock_error_logs, + script.language as _, + script_kind_for_db as _, + script.tag, + script.draft_only, + script.envs.as_deref(), + script.concurrent_limit, + script.concurrency_time_window_s, + script.cache_ttl, + script.dedicated_worker, + script.ws_error_handler_muted, + script.priority, + script.timeout, + script.delete_after_use, + script.restart_unless_cancelled, + script.concurrency_key, + script.visible_to_runner_only, + script.no_main_func, + script.codebase, + script.has_preprocessor, + script.on_behalf_of_email, + script.assets, + ) + .execute(&mut **tx) + .await?; + } + + Ok(script_hash_mapping) +} + +async fn clone_flows( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, +) -> Result<()> { + // First, clone flows without versions + sqlx::query!( + "INSERT INTO flow ( + workspace_id, path, summary, description, value, edited_by, edited_at, + archived, schema, extra_perms, dependency_job, draft_only, tag, + ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, + concurrency_key, versions, on_behalf_of_email, lock_error_logs + ) + SELECT $2, path, summary, description, value, $3, edited_at, + archived, schema, extra_perms, NULL, draft_only, tag, + ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, + concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs + FROM flow + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + target_username, + ) + .execute(&mut **tx) + .await?; + + // Then clone flow versions + let flow_versions = sqlx::query!( + "SELECT id, workspace_id, path, value, schema, created_by, created_at + FROM flow_version + WHERE workspace_id = $1 + ORDER BY path, created_at", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for version in flow_versions { + let new_version_id = sqlx::query_scalar!( + "INSERT INTO flow_version (workspace_id, path, value, schema, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id", + target_workspace_id, + version.path, + version.value, + version.schema, + target_username, + version.created_at, + ) + .fetch_one(&mut **tx) + .await?; + + // Update flow to include this version + sqlx::query!( + "UPDATE flow + SET versions = array_append(versions, $1) + WHERE workspace_id = $2 AND path = $3", + new_version_id, + target_workspace_id, + version.path, + ) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +async fn clone_flow_nodes( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO flow_node (workspace_id, hash, path, lock, code, flow, hash_v2) + SELECT $2, + (SELECT COALESCE(MAX(hash), 0) FROM flow_node) + row_number() OVER () AS new_hash, + source_fn.path, source_fn.lock, source_fn.code, source_fn.flow, source_fn.hash_v2 + FROM flow_node source_fn + WHERE source_fn.workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_apps( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + target_username: &str, +) -> Result> { + // Get all apps from source workspace + let apps = sqlx::query!( + "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path + FROM app + WHERE workspace_id = $1", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + let mut app_id_mapping: HashMap = HashMap::new(); + + // Clone apps with new IDs + for app in apps { + let new_app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id", + target_workspace_id, + app.path, + app.summary, + app.policy, + &Vec::::new(), // Start with empty versions array + app.extra_perms, + app.draft_only, + app.custom_path, + ) + .fetch_one(&mut **tx) + .await?; + + app_id_mapping.insert(app.id, new_app_id); + } + + // Clone app versions + let app_versions = sqlx::query!( + "SELECT app_id, value, created_by, created_at, raw_app + FROM app_version + WHERE app_id = ANY(SELECT id FROM app WHERE workspace_id = $1) + ORDER BY app_id, created_at", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for version in app_versions { + if let Some(&new_app_id) = app_id_mapping.get(&version.app_id) { + sqlx::query!( + "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) + VALUES ($1, $2, $3, $4, $5)", + new_app_id, + version.value, + target_username, + version.created_at, + version.raw_app, + ) + .execute(&mut **tx) + .await?; + } + } + + // Update app versions arrays + sqlx::query!( + "UPDATE app SET versions = ( + SELECT array_agg(av.id ORDER BY av.created_at) + FROM app_version av + WHERE av.app_id = app.id + ) WHERE workspace_id = $1", + target_workspace_id + ) + .execute(&mut **tx) + .await?; + + // Clone app scripts with recomputed hashes + let app_scripts = sqlx::query!( + "SELECT app, hash, lock, code, code_sha256 + FROM app_script + WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for app_script in app_scripts { + if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) { + // Recompute hash using app_id, code_sha256, and lock + let mut hasher = Sha256::new(); + hasher.update(new_app_id.to_be_bytes()); + hasher.update(hex::decode(&app_script.code_sha256)?); + if let Some(lock) = &app_script.lock { + hasher.update(lock.as_bytes()); + } + let new_hash = hex::encode(hasher.finalize()); + + sqlx::query!( + "INSERT INTO app_script (app, hash, lock, code, code_sha256) + VALUES ($1, $2, $3, $4, $5)", + new_app_id, + new_hash, + app_script.lock, + app_script.code, + app_script.code_sha256, + ) + .execute(&mut **tx) + .await?; + } + } + + Ok(app_id_mapping) +} + +async fn clone_raw_apps( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO raw_app (path, version, workspace_id, summary, edited_at, data, extra_perms) + SELECT path, version, $2, summary, edited_at, data, extra_perms + FROM raw_app + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_workspace_dependencies( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + script_hash_mapping: &HashMap, +) -> Result<()> { + let dependencies = sqlx::query!( + "SELECT flow_path, runnable_path, script_hash, runnable_is_flow, app_path + FROM workspace_runnable_dependencies + WHERE workspace_id = $1", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for dep in dependencies { + let new_script_hash = if let Some(old_hash) = dep.script_hash { + script_hash_mapping.get(&old_hash).copied() + } else { + None + }; + + sqlx::query!( + "INSERT INTO workspace_runnable_dependencies ( + flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path + ) VALUES ($1, $2, $3, $4, $5, $6)", + dep.flow_path, + dep.runnable_path, + new_script_hash, + dep.runnable_is_flow, + target_workspace_id, + dep.app_path, + ) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +async fn create_workspace_fork( + authed: ApiAuthed, + Extension(db): Extension, + Json(nw): Json, +) -> Result { + // if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { + // require_super_admin(&db, &authed.email).await?; + // } + + if *CLOUD_HOSTED { + return Err(Error::BadRequest(format!( + "Forking workspaces is not available on Cloud" + ))); + } + + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + // Generate unique forked workspace ID with wm-fork prefix + if !nw.id.starts_with(WM_FORK_PREFIX) { + return Err(Error::BadRequest(format!( + "The id `{}` is invalid for a forked workspace. It should be prefixed by {}", + nw.id, WM_FORK_PREFIX + ))); + } + + let forked_id = nw.id; + + // Determine username early so we can use it in workspace creation + let automate_username_creation = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + AUTOMATE_USERNAME_CREATION_SETTING, + ) + .fetch_optional(&mut *tx) + .await? + .map(|v| v.as_bool()) + .flatten() + .unwrap_or(false); + + let username = if automate_username_creation { + if nw.username.is_some() && nw.username.unwrap().len() > 0 { + return Err(Error::BadRequest( + "username is not allowed when username creation is automated".to_string(), + )); + } + get_instance_username_or_create_pending(&mut tx, &authed.email).await? + } else { + nw.username + .ok_or(Error::BadRequest("username is required".to_string()))? + }; + + sqlx::query!( + "INSERT INTO workspace + (id, name, owner, parent_workspace_id) + VALUES ($1, $2, $3, $4)", + forked_id, + nw.name, + authed.email, + nw.parent_workspace_id, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO workspace_settings + (workspace_id, color) + VALUES ($1, $2)", + forked_id, + nw.color, + ) + .execute(&mut *tx) + .await?; + let key = rd_string(64); + sqlx::query!( + "INSERT INTO workspace_key + (workspace_id, kind, key) + VALUES ($1, 'cloud', $2)", + forked_id, + &key + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO usr + (workspace_id, email, username, is_admin) + VALUES ($1, $2, $3, $4)", + forked_id, + authed.email, + username, + authed.is_admin, + ) + .execute(&mut *tx) + .await?; + + // Clone all data from the parent workspace using Rust implementation + clone_workspace_data(&mut tx, &nw.parent_workspace_id, &forked_id, &username, &db).await?; + + sqlx::query!( + "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) + SELECT $1, email, is_admin, operator + FROM usr + WHERE workspace_id = $2", + &forked_id, + &nw.parent_workspace_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.create_fork", + ActionKind::Create, + &forked_id, + Some(nw.name.as_str()), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("Created forked workspace {}", &forked_id)) +} + async fn edit_workspace( authed: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api/src/workspaces_extra.rs b/backend/windmill-api/src/workspaces_extra.rs index 8a92a3a05b..02996c0d32 100644 --- a/backend/windmill-api/src/workspaces_extra.rs +++ b/backend/windmill-api/src/workspaces_extra.rs @@ -1,6 +1,6 @@ use crate::db::ApiAuthed; -use crate::workspaces::{check_w_id_conflict, CREATE_WORKSPACE_REQUIRE_SUPERADMIN}; +use crate::workspaces::{check_w_id_conflict, CREATE_WORKSPACE_REQUIRE_SUPERADMIN, WM_FORK_PREFIX}; use crate::{db::DB, utils::require_super_admin}; use axum::{ @@ -8,6 +8,7 @@ use axum::{ Json, }; +use sqlx::{Postgres, Transaction}; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; @@ -429,7 +430,9 @@ pub(crate) async fn delete_workspace( _ => Ok(w_id), }?; let mut tx = db.begin().await?; - require_super_admin(&db, &authed.email).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?; + } sqlx::query!("DELETE FROM dependency_map WHERE workspace_id = $1", &w_id) .execute(&mut *tx) @@ -574,3 +577,14 @@ pub(crate) async fn delete_workspace( Ok(format!("Deleted workspace {}", &w_id)) } + +async fn is_workspace_owner( + authed: &ApiAuthed, + w_id: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result { + let owner = sqlx::query_scalar!("SELECT owner FROM workspace WHERE id = $1", w_id) + .fetch_optional(&mut **tx) + .await?; + Ok(owner.map(|o| o == authed.email).unwrap_or(false)) +} diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts new file mode 100644 index 0000000000..947edca64a --- /dev/null +++ b/cli/src/commands/workspace/fork.ts @@ -0,0 +1,243 @@ +// deno-lint-ignore-file no-explicit-any +import { GlobalOptions } from "../../types.ts"; +import { colors, Command, Input, log, setClient } from "../../../deps.ts"; +import { requireLogin } from "../../core/auth.ts"; +import { add, addWorkspace, allWorkspaces, getActiveWorkspace, list, removeWorkspace } from "./workspace.ts"; +import { loginInteractive, tryGetLoginInfo } from "../../core/login.ts"; +import * as wmill from "../../../gen/services.gen.ts"; +import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../../utils/git.ts"; +import { WM_FORK_PREFIX } from "../../main.ts"; +import { tryResolveBranchWorkspace } from "../../core/context.ts"; + +// NOTE: This import will work after regenerating the API client +// Run ./gen_wm_client.sh to regenerate after backend changes +// import * as wmill from "../../../gen/services.gen.ts"; + +async function runGitCommand( + args: string[], +): Promise<{ success: boolean; output: string }> { + try { + const command = new Deno.Command("git", { + args, + stdout: "piped", + stderr: "piped", + }); + + const { code, stdout, stderr } = await command.output(); + const output = new TextDecoder().decode(code === 0 ? stdout : stderr); + + return { + success: code === 0, + output: output.trim(), + }; + } catch (error) { + return { + success: false, + output: `Failed to execute git command: ${error.message}`, + }; + } +} + +async function createWorkspaceFork( + opts: GlobalOptions, + workspaceName: string | undefined, + workspaceId: string | undefined = undefined, +) { + if (!isGitRepository()) { + throw new Error("You can only create forks within a git repo. Forks are tracked with git and synced to your instance with the git sync workflow."); + } + + const workspace = await tryResolveBranchWorkspace(opts); + + if (!workspace) { + throw new Error("Could not resolve workspace from branch name. Make sure you are in a git repo to use workspace forks"); + } + + log.info(`You are forking workspace (${workspace.workspaceId})`) + + const currentBranch = getCurrentGitBranch() + const originalBranchIfForked = getOriginalBranchForWorkspaceForks(currentBranch); + + let clonedBranchName: string | null; + if (originalBranchIfForked) { + log.info(`You are creating a fork of a fork. The branch will be linked to the original branch this was forked from, i.e. \`${originalBranchIfForked}\`, for all settings and overrides.`); + clonedBranchName = originalBranchIfForked; + } else { + clonedBranchName = currentBranch; + } + + if (!clonedBranchName) { + throw new Error("Failed to get current branch name, aborting operation"); + } + + if (opts.workspace) { + log.info( + colors.red.bold( + "! Workspace needs to be specified as positional argument, not as option." + ) + ); + return; + } + + while (workspaceName === undefined) { + if (!workspaceName) { + workspaceName = await Input.prompt("Name this forked workspace:"); + } + } + + if (!workspaceId) { + workspaceId = await Input.prompt({ + message: `Enter the ID of this forked workspace, it will then be prefixed by ${WM_FORK_PREFIX}. It will also determine the branch name`, + default: workspaceName, + suggestions: [workspaceName], + }); + } + + const token = workspace.token; + + if (!token) { + throw new Error("Not logged in. Please run 'wmill workspace add' first."); + } + + const remote = workspace.remote + setClient( + token, + remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote + ); + + log.info(colors.blue(`Creating forked workspace: ${workspaceName}...`)); + + const trueWorkspaceId = `${WM_FORK_PREFIX}-${workspaceId}`; + let alreadyExists = false; + try { + alreadyExists = await wmill.existsWorkspace({ + requestBody: { id: trueWorkspaceId }, + }); + } catch (e) { + log.info( + colors.red.bold("! Credentials or instance is invalid. Aborting.") + ); + throw e; + } + + if (alreadyExists) { + throw new Error(`This forked workspace '${workspaceId}' (${workspaceName}) already exists. Choose a different id`); + } + + try { + // TODO: Update to createWorkspaceFork after regenerating client from new OpenAPI spec + const result = await wmill.createWorkspaceFork({ + requestBody: { + id: trueWorkspaceId, + name: workspaceName, + username: undefined, // Let the server handle username + color: undefined, + parent_workspace_id: workspace.workspaceId, + }, + }); + + log.info(colors.green(`✅ ${result}`)); + + } catch (error) { + // If workspace creation fails, we should clean up the git branch + log.error( + colors.red(`Failed to create forked workspace: ${error.message}`), + ); + throw error; + } + + await addWorkspace( + { + name: workspaceName, + remote: remote, + workspaceId: trueWorkspaceId, + token: token, + }, + opts + ); + + const newBranchName = `${WM_FORK_PREFIX}/${clonedBranchName}/${workspaceId}` + + log.info(`Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command:\n\n\t`+colors.white(`git checkout -b ${newBranchName}`) + `\n\nThe changes will then be reflected in your fork if you've setup the git sync workflows correctly.`); +} + +async function deleteWorkspaceFork( + opts: GlobalOptions & { + yes?: boolean; + }, + silent: boolean, + name: string, +) { + + const orgWorkspaces = await allWorkspaces(opts.configDir); + const idxOf = orgWorkspaces.findIndex((x) => x.name === name) ; + if (idxOf === -1) { + if (!silent) { + log.info( + colors.red.bold(`! Workspace profile ${name} does not exist locally`) + ); + log.info("available workspace profiles:"); + await list(opts); + } + return; + } + + const workspace = orgWorkspaces[idxOf]; + + if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) { + throw new Error( + `You can only delete forked workspaces where the workspace id starts with \`${WM_FORK_PREFIX}.\` Failed while attempting to delete \`${workspace.workspaceId}\``, + ); + } + + if (!opts.yes) { + const { Select } = await import("../../../deps.ts"); + const choice = await Select.prompt({ + message: `Are you sure you want to delete the forked workspace with id: \`${workspace.workspaceId}\`? This action will delete the workspace `, + options: [ + { name: "Yes", value: "confirm" }, + { name: "No", value: "cancel" }, + ], + }); + + if (choice === "cancel") { + log.info("Operation cancelled"); + return; + } + } + + const remote = workspace.remote + setClient( + workspace.token, + remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote + ); + + const result = await wmill.deleteWorkspace({ + workspace: workspace.workspaceId + }); + log.info( + colors.green(`✅ Forked workspace '${workspace.workspaceId}' deleted successfully!\n${result}`), + ); + await removeWorkspace(name, silent, opts); +} + +const forkCommand = new Command() + .description("Create a forked workspace and git branch") + .arguments("[workspace_id:string]") + .option( + "--create-workspace-name ", + "Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id." + ) + .action(async (opts: GlobalOptions, workspace_id: string) => { + await requireLogin(opts); + await createWorkspaceFork(opts, workspace_id, undefined); + }); + +const deleteForkCommand = new Command() + .description("Delete a forked workspace and git branch") + .arguments("") + .action(async (opts: GlobalOptions, name: string, silent: boolean) => { + await deleteWorkspaceFork(opts, silent, name); + }); + +export { forkCommand, deleteForkCommand }; diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 335b91be8f..8270de39df 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -4,6 +4,7 @@ import { getActiveWorkspaceConfigFilePath, getWorkspaceConfigFilePath } from ".. import { loginInteractive, tryGetLoginInfo } from "../../core/login.ts"; import { colors, Command, Confirm, Input, log, setClient, Table } from "../../../deps.ts"; import { requireLogin } from "../../core/auth.ts"; +import { forkCommand, deleteForkCommand } from "./fork.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -70,7 +71,7 @@ export async function getWorkspaceByName( return undefined; } -async function list(opts: GlobalOptions) { +export async function list(opts: GlobalOptions) { const workspaces = await allWorkspaces(opts.configDir); const activeName = await getActiveWorkspaceName(opts); @@ -464,6 +465,8 @@ const command = new Command() .command("unbind") .description("Remove workspace binding from the current Git branch") .option("--branch ", "Specify branch (defaults to current)") - .action((opts) => bind(opts as any, false)); + .action((opts) => bind(opts as any, false)) + .command("fork", forkCommand) + .command("delete-fork", deleteForkCommand); export default command; diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index b64d48b915..18a8902079 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -1,5 +1,5 @@ import { log, yamlParseFile, Confirm, yamlStringify } from "../../deps.ts"; -import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; +import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../utils/git.ts"; import { join, dirname, resolve, relative } from "node:path"; import { existsSync } from "node:fs"; import { execSync } from "node:child_process"; @@ -313,7 +313,18 @@ export async function validateBranchConfiguration(skipValidation?: boolean, auto const config = await readConfigFile(); const { gitBranches } = config; - const currentBranch = getCurrentGitBranch(); + + const rawBranch = getCurrentGitBranch(); + + const originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); + + let currentBranch: string | null; + if (originalBranchIfForked) { + log.info(`Workspace fork detected from branch name \`${rawBranch}\`. Validating branch configuration using original branch \`${originalBranchIfForked}\``); + currentBranch = originalBranchIfForked; + } else { + currentBranch = rawBranch; + } // In a git repository, gitBranches section is recommended if (!gitBranches || Object.keys(gitBranches).length === 0) { @@ -389,7 +400,17 @@ export async function getEffectiveSettings(config: SyncOptions, promotion?: stri let effective = { ...topLevelSettings }; if (isGitRepository()) { - const currentBranch = getCurrentGitBranch(); + const branch = getCurrentGitBranch(); + + const originalBranchIfForked = getOriginalBranchForWorkspaceForks(branch); + + let currentBranch: string | null; + if (originalBranchIfForked) { + log.info(`Using overrides from original branch \`${originalBranchIfForked}\``); + currentBranch = originalBranchIfForked; + } else { + currentBranch = branch + } // If promotion is specified, use that branch's promotionOverrides or overrides if (promotion && gitBranches && gitBranches[promotion]) { @@ -414,7 +435,8 @@ export async function getEffectiveSettings(config: SyncOptions, promotion?: stri else if (currentBranch && gitBranches && gitBranches[currentBranch] && gitBranches[currentBranch].overrides) { Object.assign(effective, gitBranches[currentBranch].overrides); if (!suppressLogs) { - log.info(`Applied settings for Git branch: ${currentBranch}`); + const extraLog = originalBranchIfForked ? ` (because it is the origin of the workspace fork branch \`${branch}\`)` : ""; + log.info(`Applied settings for Git branch: ${currentBranch}${extraLog}`); } } else if (currentBranch) { log.debug(`No branch-specific overrides found for '${currentBranch}', using top-level settings`); diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index 92a6276aa3..902c9b7435 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -17,7 +17,8 @@ import { setLastUsedProfile } from "./branch-profiles.ts"; import { readConfigFile } from "./conf.ts"; -import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; +import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, getWorkspaceIdForWorkspaceForkFromBranchName, isGitRepository } from "../utils/git.ts"; +import { WM_FORK_PREFIX } from "../main.ts"; // Helper function to select from multiple matching profiles async function selectFromMultipleProfiles( @@ -116,7 +117,7 @@ async function tryResolveWorkspace( }; } -async function tryResolveBranchWorkspace( +export async function tryResolveBranchWorkspace( opts: GlobalOptions ): Promise { // Only try branch-based resolution if in a Git repository @@ -124,11 +125,20 @@ async function tryResolveBranchWorkspace( return undefined; } - const currentBranch = getCurrentGitBranch(); - if (!currentBranch) { + const rawBranch = getCurrentGitBranch(); + if (!rawBranch) { return undefined; } + let currentBranch: string; + const originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); + const workspaceIdIfForked = getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); + if (originalBranchIfForked) { + currentBranch = originalBranchIfForked; + } else { + currentBranch = rawBranch; + } + // Read wmill.yaml to check for branch workspace configuration const config = await readConfigFile(); const branchConfig = config.gitBranches?.[currentBranch]; @@ -138,7 +148,11 @@ async function tryResolveBranchWorkspace( return undefined; } - const { baseUrl, workspaceId } = branchConfig; + let { baseUrl, workspaceId } = branchConfig; + if (workspaceIdIfForked) { + workspaceId = workspaceIdIfForked; + log.info(`Inferred workspace id \`${workspaceId}\` from branch name because this is a workspace fork branch (\`${rawBranch}\`). `); + } let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(baseUrl).toString(); @@ -155,10 +169,17 @@ async function tryResolveBranchWorkspace( if (matchingProfiles.length === 0) { // No matching profile exists - prompt to create one - log.info(colors.yellow( - `\nNo workspace profile found for branch '${currentBranch}'\n` + - `(${normalizedBaseUrl}, ${workspaceId})` - )); + if (!originalBranchIfForked) { + log.info(colors.yellow( + `\nNo workspace profile found for branch '${rawBranch}'\n` + + `(${normalizedBaseUrl}, ${workspaceId})` + )); + } else { + log.info(colors.yellow( + `\nNo workspace profile was found for this forked workspace\n` + + `(${normalizedBaseUrl}, ${workspaceId})` + )); + } if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) { log.info("Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first."); @@ -166,7 +187,7 @@ async function tryResolveBranchWorkspace( } const shouldCreate = await Confirm.prompt({ - message: "Would you like to create a new profile?", + message: "Would you like to create a new workspace profile?", default: true, }); @@ -327,10 +348,17 @@ export async function resolveWorkspace( } } - // Try explicit workspace flag first (highest priority) + const branch = getCurrentGitBranch(); + + // Try explicit workspace flag first (should override branch-based resolution). Unless it's a + // forked workspace, that we detect through the branch name const res = await tryResolveWorkspace(opts); if (!res.isError) { - return res.value; + if (!branch || !branch.startsWith(WM_FORK_PREFIX)) { + return res.value; + } else { + log.info(`Found an active workspace \`${res.value.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\``); + } } // Try branch-based resolution (medium priority) @@ -338,6 +366,12 @@ export async function resolveWorkspace( if (branchWorkspace) { (opts as any).__secret_workspace = branchWorkspace; return branchWorkspace; + } else { + const originalBranch = getOriginalBranchForWorkspaceForks(branch) + if (originalBranch) { + log.error(colors.red.bold(`Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.`)) + return Deno.exit(-1); + } } // Fall back to active workspace (lowest priority) diff --git a/cli/src/main.ts b/cli/src/main.ts index 48a378fc5e..f38effceca 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -70,6 +70,8 @@ export { export const VERSION = "1.541.1"; +export const WM_FORK_PREFIX = "wm-fork"; + const command = new Command() .name("wmill") .action(() => diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index c1032a8218..85d68052fc 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -1,5 +1,6 @@ import { log } from "../../deps.ts"; import { execSync } from "node:child_process"; +import { WM_FORK_PREFIX } from "../main.ts"; export function getCurrentGitBranch(): string | null { try { @@ -15,6 +16,34 @@ export function getCurrentGitBranch(): string | null { } } +export function getOriginalBranchForWorkspaceForks(branchName: string): string | null { + if (!branchName.startsWith(WM_FORK_PREFIX)) { + return null + } + + const start = branchName.indexOf("/") + 1; + const end = branchName.lastIndexOf("/"); + + if (start < 0 || end < 0 || end - start <= 0) { + return null + } + + return branchName.slice(start, end) +} + +export function getWorkspaceIdForWorkspaceForkFromBranchName(branchName: string): string | null { + if (!branchName.startsWith(WM_FORK_PREFIX)) { + return null + } + + const start = branchName.lastIndexOf("/") + 1; + + if (start < 0) { + return null + } + + return `${WM_FORK_PREFIX}-${branchName.slice(start)}` +} export function isGitRepository(): boolean { try { execSync("git rev-parse --git-dir", { diff --git a/flake.nix b/flake.nix index 5daca23d31..3bd150a614 100644 --- a/flake.nix +++ b/flake.nix @@ -107,6 +107,34 @@ glibc_multi ]); }; + devShells."cli" = pkgs.mkShell { + shellHook = '' + if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + export FLAKE_ROOT="$(git rev-parse --show-toplevel)" + else + # Fallback to PWD if not in a git repository + export FLAKE_ROOT="$PWD" + fi + wm-cli-deps + ''; + buildInputs = buildInputs ++ [ + pkgs.deno + ]; + packages = [ + (pkgs.writeScriptBin "wm-cli" '' + deno run -A --no-check $FLAKE_ROOT/cli/src/main.ts $* + '') + (pkgs.writeScriptBin "wm-cli-deps" '' + pushd $FLAKE_ROOT/cli/ + ${ + if pkgs.stdenv.isDarwin + then "./gen_wm_client_mac.sh && ./windmill-utils-internal/gen_wm_client_mac.sh" + else "./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh" + } + popd + '') + ]; + }; devShells.default = pkgs.mkShell { buildInputs = buildInputs ++ [ @@ -254,7 +282,6 @@ ANSIBLE_GALAXY_PATH = "${pkgs.ansible}/bin/ansible-galaxy"; # RUST_LOG = "debug"; # RUST_LOG = "kube=debug"; - SQLX_OFFLINE = "true"; # See this issue: https://github.com/NixOS/nixpkgs/issues/370494 # Allows to build jemalloc on nixos diff --git a/frontend/src/lib/components/PrefixedInput.svelte b/frontend/src/lib/components/PrefixedInput.svelte new file mode 100644 index 0000000000..c548418855 --- /dev/null +++ b/frontend/src/lib/components/PrefixedInput.svelte @@ -0,0 +1,138 @@ + + + + + diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index ca4f99447b..5a64d3c450 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -155,7 +155,7 @@ export function createGitSyncContext(workspace: string) { use_individual_branch: repo.use_individual_branch, group_by_folder: repo.group_by_folder, settings: repo.settings, - exclude_types_override: repo.exclude_types_override + exclude_types_override: repo.exclude_types_override, } } diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index d4b87508d2..23498246f7 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -294,7 +294,6 @@ {/snippet} {/if} - {#if !repo.isUnsavedConnection}
diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 866d5bd84d..f45c698be5 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -35,6 +35,7 @@ AlertCircle, Database, Pyramid, + Trash2, MailIcon } from 'lucide-svelte' import UserMenu from './UserMenu.svelte' @@ -71,6 +72,13 @@ goto('/user/workspaces') } + async function deleteFork() { + await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' }) + sendUserToast('You deleted the workspace') + clearStores() + goto('/user/workspaces') + } + let hasNewChangelogs = $state(false) let recentChangelogs: Changelog[] = $state([]) let lastOpened = localStorage.getItem('changelogsLastOpened') @@ -137,6 +145,7 @@ let { numUnacknowledgedCriticalAlerts = 0, isCollapsed = false }: Props = $props() let leaveWorkspaceModal = $state(false) + let deleteWorkspaceForkModal = $state(false) function computeAllNotificationsCount(menuItems: any[]) { let count = 0 @@ -349,7 +358,19 @@ faIcon: undefined } ] - : []) + : []), + ...($workspaceStore?.startsWith("wm-fork") + ? [ + { + label: 'Delete Forked Workspace', + action: () => { + deleteWorkspaceForkModal = true + }, + icon: Trash2, + faIcon: undefined + } + ] + : []), ], disabled: $userStore?.operator }, @@ -643,3 +664,21 @@ Are you sure you want to leave this workspace?
+ +{#if $workspaceStore?.startsWith("wm-fork-")} + { + deleteWorkspaceForkModal = false + }} + on:confirmed={() => { + deleteFork() + }} +> +
+ Are you sure you want to delete this workspace fork? (deleting {$workspaceStore}) +
+
+{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index d7a3d52a9c..60f377f224 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -8,7 +8,7 @@ workspaceUsageStore, workspaceColor } from '$lib/stores' - import { Building, Plus, Settings } from 'lucide-svelte' + import { Building, Plus, Settings, GitFork } from 'lucide-svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import { Menu, MenuItem } from '$lib/components/meltComponents' import { goto } from '$lib/navigation' @@ -21,6 +21,7 @@ import { workspaceAIClients } from '../copilot/lib' import { twMerge } from 'tailwind-merge' import type { MenubarBuilders } from '@melt-ui/svelte' + import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy' interface Props { isCollapsed?: boolean @@ -29,6 +30,13 @@ strictWorkspaceSelect?: boolean } + function removePrefix(str: string, prefix: string): string { + if (str.startsWith(prefix)) { + return str.substring(prefix.length) + } + return str + } + let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props() async function toggleSwitchWorkspace(id: string) { @@ -56,24 +64,79 @@ await goto('/') } } + + // Helper function to check if a workspace is forked + function isForkedWorkspace(workspaceId: string): boolean { + if (!$userWorkspaces) return false + return $userWorkspaces.some((w) => w.id === workspaceId && w.parent_workspace_id != null) + } + + function getForkedWorkspace(workspaceId: string) { + if (!$userWorkspaces) return undefined + return $userWorkspaces.find((w) => w.id === workspaceId && w.parent_workspace_id != null) + } + + function getParentWorkspace(parentId: string) { + if (!$userWorkspaces) return undefined + return $userWorkspaces.find((w) => w.id === parentId) + } + + // Group workspaces into parent-child hierarchy using Svelte 5 derived and the new utility + const groupedWorkspaces = $derived(() => { + if (!$userWorkspaces) return [] + return buildWorkspaceHierarchy($userWorkspaces) + }) +{#if isForkedWorkspace($workspaceStore ?? '') && !isCollapsed} + {@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')} + {@const parentWorkspace = forkedWorkspace + ? getParentWorkspace(forkedWorkspace.parent_workspace_id!) + : null} + + {#snippet triggr({ trigger })} +
+ + {parentWorkspace?.name ?? ''} +
+ {/snippet} +
+{/if} {#snippet triggr({ trigger })} - + {@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')} + {@const parentWorkspace = forkedWorkspace + ? getParentWorkspace(forkedWorkspace.parent_workspace_id!) + : null} + {#if forkedWorkspace && parentWorkspace} +
+ +
+ {:else} + + {/if} {/snippet} {#snippet children({ item })}
- {#each $userWorkspaces as workspace} + {#each groupedWorkspaces() as { workspace, depth, isForked, parentName }}
-
-
{workspace.name}
-
- {workspace.id} +
+ {#if isForked} + + {:else} + + {/if} +
+
+ {workspace.name} +
+
+ {workspace.id} +
+ {#if isForked && parentName} +
+ Fork of {parentName} +
+ {/if}
{#if workspace.color} @@ -119,6 +206,19 @@
{/if} + {#if !strictWorkspaceSelect} + + {/if} {#if !strictWorkspaceSelect}
+ import { run } from 'svelte/legacy' + + import { goto } from '$lib/navigation' + import { base } from '$lib/base' + import { + ResourceService, + SettingService, + UserService, + VariableService, + WorkspaceService, + type AIProvider + } from '$lib/gen' + import { validateUsername } from '$lib/utils' + import { logoutWithRedirect } from '$lib/logout' + import { page } from '$app/stores' + import { usersWorkspaceStore, workspaceStore } from '$lib/stores' + import CenteredModal from '$lib/components/CenteredModal.svelte' + import { Button } from '$lib/components/common' + import Toggle from '$lib/components/Toggle.svelte' + import Tooltip from '$lib/components/Tooltip.svelte' + import { onMount } from 'svelte' + import { sendUserToast } from '$lib/toast' + import TestAIKey from '$lib/components/copilot/TestAIKey.svelte' + import { switchWorkspace } from '$lib/storeUtils' + import { isCloudHosted } from '$lib/cloud' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import { AI_PROVIDERS } from '$lib/components/copilot/lib' + import { GitFork } from 'lucide-svelte' + import PrefixedInput from '../PrefixedInput.svelte' + + interface Props { + isFork?: boolean + } + + let { isFork = false }: Props = $props() + + const rd = $page.url.searchParams.get('rd') + + let id = $state('') + let name = $state('') + let username = $state('') + + let errorId = $state('') + let errorUser = $state('') + let aiKey = $state('') + let codeCompletionEnabled = $state(true) + let checking = $state(false) + + let workspaceColor: string | null = $state(null) + let colorEnabled = $state(false) + + function generateRandomColor() { + const randomColor = + '#' + + Math.floor(Math.random() * 16777215) + .toString(16) + .padStart(6, '0') + workspaceColor = randomColor + } + + async function validateName(id: string): Promise { + checking = true + let exists = await WorkspaceService.existsWorkspace({ requestBody: { id } }) + if (exists) { + errorId = 'ID already exists' + } else if (id != '' && !/^\w+(-\w+)*$/.test(id)) { + errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash' + } else { + errorId = '' + } + checking = false + } + + const WM_FORK_PREFIX = 'wm-fork-' + + async function createOrForkWorkspace() { + const prefixed_id = `${WM_FORK_PREFIX}${id}` + if (isFork) { + if ($workspaceStore) { + await WorkspaceService.createWorkspaceFork({ + requestBody: { + id: prefixed_id, + name, + color: colorEnabled && workspaceColor ? workspaceColor : undefined, + username: automateUsernameCreation ? undefined : username, + parent_workspace_id: $workspaceStore + } + }) + + sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`) + } else { + sendUserToast('No workspace selected, cannot fork non-existent workspace', true) + } + } else { + await createWorkspace() + } + + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(isFork ? prefixed_id : id) + + goto(rd ?? '/') + } + + async function createWorkspace(): Promise { + await WorkspaceService.createWorkspace({ + requestBody: { + id, + name, + color: colorEnabled && workspaceColor ? workspaceColor : undefined, + username: automateUsernameCreation ? undefined : username + } + }) + if (auto_invite) { + await WorkspaceService.editAutoInvite({ + workspace: id, + requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: true } + }) + } + if (aiKey != '') { + let actualUsername = username + if (automateUsernameCreation) { + const user = await UserService.whoami({ + workspace: id + }) + actualUsername = user.username + } + let path = `u/${actualUsername}/${selected}_windmill_codegen` + await VariableService.createVariable({ + workspace: id, + requestBody: { + path, + value: aiKey, + is_secret: true, + description: 'Ai token' + } + }) + await ResourceService.createResource({ + workspace: id, + requestBody: { + path, + value: { + api_key: '$var:' + path + }, + resource_type: selected + } + }) + await WorkspaceService.editCopilotConfig({ + workspace: id, + requestBody: aiKey + ? { + providers: { + [selected]: { + resource_path: path, + models: [AI_PROVIDERS[selected].defaultModels[0]] + } + }, + default_model: { + model: AI_PROVIDERS[selected].defaultModels[0], + provider: selected + }, + code_completion_model: codeCompletionEnabled + ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } + : undefined + } + : {} + }) + } + + sendUserToast(`Created workspace id: ${id}`) + + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(id) + + goto(rd ?? '/') + } + + function handleKeyUp(event: KeyboardEvent) { + const key = event.key + if (key === 'Enter') { + event.preventDefault() + createWorkspace() + } + } + + async function loadWorkspaces() { + if (!$usersWorkspaceStore) { + try { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + } catch {} + } + if (!$usersWorkspaceStore) { + const url = $page.url + console.log('logout 2') + await logoutWithRedirect(url.href.replace(url.origin, '')) + } + } + + let automateUsernameCreation = $state(false) + async function getAutomateUsernameCreationSetting() { + automateUsernameCreation = + ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? false + + if (!automateUsernameCreation) { + UserService.globalWhoami().then((x) => { + let uname = '' + if (x.name) { + uname = x.name.split(' ')[0] + } else { + uname = x.email.split('@')[0] + } + uname = uname.replace(/\./gi, '') + username = uname.toLowerCase() + }) + } + } + getAutomateUsernameCreationSetting() + + onMount(() => { + loadWorkspaces() + + WorkspaceService.isDomainAllowed().then((x) => { + isDomainAllowed = x + }) + }) + + let isDomainAllowed: undefined | boolean = $state(undefined) + + let auto_invite = $state(false) + let operatorOnly = $state(false) + let selected: Exclude = $state('openai') + run(() => { + id = name.toLowerCase().replace(/\s/gi, '-') + }) + run(() => { + validateName(id) + }) + run(() => { + errorUser = validateUsername(username) + }) + run(() => { + colorEnabled && !workspaceColor && generateRandomColor() + }) + let domain = $derived($usersWorkspaceStore?.email.split('@')[1]) + + + + {#if isFork} +
+ + Forking + + {$workspaceStore} + +
+ {/if} + + + + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
+ + +
+ + +
+ {#if aiKey} + + {/if} +
+ + {#if isCloudHosted() && isDomainAllowed == false} +
{domain} domain not allowed for auto-invite
+ {/if} +
+
+ Mode Whether to invite or add users directly to the workspace. +
+ +
Role Role of the auto-invited users
+ { + operatorOnly = e.detail == 'operator' + }} + > + {#snippet children({ item })} + + + {/snippet} + +
+ {/if} +
+ + +
+
diff --git a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte index ca80c68cfb..e73cb40e0c 100644 --- a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte @@ -35,7 +35,7 @@ isInitialSetup = false, requiresMigration = false, actions = undefined, - useIndividualBranch = false + useIndividualBranch = false, } = $props() // Component state diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 72ad185d0d..4b86c823f9 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -13,14 +13,16 @@ "gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", "gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", "gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", - "gitSync": "hub/19803/sync-script-to-git-repo-windmill", + "gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", + "gitSync": "hub/19816/sync-script-to-git-repo-windmill", "gitSyncTest_0": "hub/9073/git-repo-test-read-write-windmill", "gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill", "gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill", "gitSyncTest_3": "hub/11669/git-repo-test-read-write-windmill", "gitSyncTest": "hub/19799/git-repo-test-read-write-windmill", "gitInitRepo_0": "hub/19787/git-sync%3A-init-repository-windmill", - "gitInitRepo": "hub/19797/git-sync%3A-init-repository-windmill", + "gitInitRepo_1": "hub/19797/git-sync%3A-init-repository-windmill", + "gitInitRepo": "hub/19817/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 8a8bc697e4..61f22fc478 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -33,8 +33,9 @@ export interface UserWorkspace { id: string name: string username: string - color: string | null + color?: string operator_settings?: OperatorSettings + parent_workspace_id?: string | null } const persistedWorkspace = BROWSER && getWorkspace() @@ -86,8 +87,8 @@ export const userWorkspaces: Readable> = derived( id: 'admins', name: 'Admins', username: 'superadmin', - color: null, - operator_settings: null + color: undefined, + operator_settings: undefined } ] } else { diff --git a/frontend/src/lib/utils/workspaceHierarchy.ts b/frontend/src/lib/utils/workspaceHierarchy.ts new file mode 100644 index 0000000000..fb1114362e --- /dev/null +++ b/frontend/src/lib/utils/workspaceHierarchy.ts @@ -0,0 +1,129 @@ +import type { UserWorkspace } from '../stores' + +export interface WorkspaceHierarchyItem { + workspace: UserWorkspace + depth: number + isForked: boolean + parentName?: string + hasChildren: boolean +} + +/** + * Builds a hierarchical structure from a flat array of workspaces. + * Supports unlimited nesting levels (fork of fork of fork...). + * Returns a flattened array with hierarchy metadata for easy rendering. + */ +export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceHierarchyItem[] { + if (!workspaces || workspaces.length === 0) { + return [] + } + + // Create maps for quick lookups + const workspaceMap = new Map(workspaces.map(w => [w.id, w])) + const childrenMap = new Map() + const hasChildrenSet = new Set() + + // Build children mapping and track which workspaces have children + for (const workspace of workspaces) { + if (workspace.parent_workspace_id) { + if (!childrenMap.has(workspace.parent_workspace_id)) { + childrenMap.set(workspace.parent_workspace_id, []) + } + childrenMap.get(workspace.parent_workspace_id)!.push(workspace) + hasChildrenSet.add(workspace.parent_workspace_id) + } + } + + // Find root workspaces (those without a parent or whose parent is not in the current list) + const rootWorkspaces = workspaces.filter(w => { + if (!w.parent_workspace_id) { + return true // Definitely a root + } + // Check if parent exists in the current workspace list + return !workspaceMap.has(w.parent_workspace_id) + }) + + const result: WorkspaceHierarchyItem[] = [] + + // Recursively build the hierarchy + function addWorkspaceAndChildren(workspace: UserWorkspace, depth: number, isForked: boolean, parentName?: string) { + // Add the current workspace + result.push({ + workspace, + depth, + isForked, + parentName, + hasChildren: hasChildrenSet.has(workspace.id) + }) + + // Add its children (sorted by name for consistency) + const children = childrenMap.get(workspace.id) || [] + children + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach(child => { + addWorkspaceAndChildren(child, depth + 1, true, workspace.name) + }) + } + + // Process root workspaces (sorted by name for consistency) + rootWorkspaces + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach(workspace => { + const isRootForked = workspace.parent_workspace_id != null + const parentName = isRootForked && workspace.parent_workspace_id + ? workspace.parent_workspace_id // Use parent ID as fallback if parent not in list + : undefined + + addWorkspaceAndChildren(workspace, 0, isRootForked, parentName) + }) + + return result +} + +/** + * Helper function to get the indentation padding based on depth. + * Each level adds 24px of left padding. + */ +export function getWorkspaceIndentation(depth: number): string { + return `${depth * 24}px` +} + +/** + * Helper function to check if a workspace is a root workspace + */ +export function isRootWorkspace(workspace: UserWorkspace): boolean { + return workspace.parent_workspace_id == null +} + +/** + * Helper function to find all descendants of a workspace + */ +export function findWorkspaceDescendants( + workspaceId: string, + allWorkspaces: UserWorkspace[] +): UserWorkspace[] { + const descendants: UserWorkspace[] = [] + const childrenMap = new Map() + + // Build children mapping + for (const workspace of allWorkspaces) { + if (workspace.parent_workspace_id) { + if (!childrenMap.has(workspace.parent_workspace_id)) { + childrenMap.set(workspace.parent_workspace_id, []) + } + childrenMap.get(workspace.parent_workspace_id)!.push(workspace) + } + } + + // Recursively find descendants + function collectDescendants(id: string) { + const children = childrenMap.get(id) || [] + for (const child of children) { + descendants.push(child) + collectDescendants(child.id) + } + } + + collectDescendants(workspaceId) + return descendants +} \ No newline at end of file diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/create_workspace/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/create_workspace/+page.svelte index 37e9f733a5..404b6c2e38 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/create_workspace/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/create_workspace/+page.svelte @@ -1,352 +1,6 @@ - - - - - {#if !automateUsernameCreation} - - {/if} -
- - -
- - -
- {#if aiKey} - - {/if} -
- - {#if isCloudHosted() && isDomainAllowed == false} -
{domain} domain not allowed for auto-invite
- {/if} -
-
- Mode Whether to invite or add users directly to the workspace. -
- -
Role Role of the auto-invited users
- { - operatorOnly = e.detail == 'operator' - }} - > - {#snippet children({ item })} - - - {/snippet} - -
-
- - -
-
+ diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte new file mode 100644 index 0000000000..c98509f3db --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 8f18d94ce5..71a2b1a006 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -20,17 +20,18 @@ import CenteredModal from '$lib/components/CenteredModal.svelte' import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings' import { switchWorkspace } from '$lib/storeUtils' - import { Cog, Crown } from 'lucide-svelte' + import { Cog, Crown, GitFork } from 'lucide-svelte' import { isCloudHosted } from '$lib/cloud' import { emptyString } from '$lib/utils' import { getUserExt } from '$lib/user' import { refreshSuperadmin } from '$lib/refreshUser' + import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy' + import type { UserWorkspace } from '$lib/stores' let invites: WorkspaceInvite[] = [] let list_all_as_super_admin: boolean = false - let workspaces: - | { id: string; name: string; username: string; color?: string | null }[] - | undefined = undefined + let workspaces: UserWorkspace[] | undefined = undefined + let showAllForks: boolean = false let userSettings: UserSettings let superadminSettings: SuperadminSettings @@ -84,8 +85,20 @@ $: adminsInstance = workspaces?.find((x) => x.id == 'admins') || $superadmin - $: nonAdminWorkspaces = (workspaces ?? []).filter((x) => x.id != 'admins') - $: noWorkspaces = $superadmin && nonAdminWorkspaces.length == 0 + + // Complete workspace hierarchy with all forks + $: forkedWorkspacesHierarchy = (() => { + if (!workspaces) return [] + + // Filter out admin workspace + const nonAdminWorkspaces = workspaces.filter((x) => x.id !== 'admins') + + return buildWorkspaceHierarchy(nonAdminWorkspaces) + })() + + + $: groupedNonAdminWorkspaces = forkedWorkspacesHierarchy + $: noWorkspaces = $superadmin && groupedNonAdminWorkspaces.length == 0 async function getCreateWorkspaceRequireSuperadmin() { const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin') @@ -190,26 +203,38 @@ workspace.

{/if} - {#each nonAdminWorkspaces as workspace (workspace.id)} -
+
+ {/each} + {/if} + {#if workspaces} +
+ +
+ {/if} +
{#if $superadmin}