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] <windmill-internal-app[bot]@users.noreply.github.com>

* 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] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
wendrul
2025-09-10 19:42:45 +02:00
committed by GitHub
parent b7125074a2
commit 0bf200d26c
62 changed files with 3506 additions and 436 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
8f539adc1b5331de3c2cd539712526d653bfbabf
254a6b563c503fb09d2dc332de70c2173b96491c
@@ -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;
@@ -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;
+55
View File
@@ -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
+8 -1
View File
@@ -311,6 +311,7 @@ pub struct WorkspaceInvite {
pub email: String,
pub is_admin: bool,
pub operator: bool,
pub parent_workspace_id: Option<String>,
}
#[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)
+909 -6
View File
@@ -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<String>,
parent_workspace_id: Option<String>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -332,6 +338,15 @@ struct CreateWorkspace {
color: Option<String>,
}
#[derive(Deserialize)]
struct CreateWorkspaceFork {
id: String,
name: String,
username: Option<String>,
color: Option<String>,
parent_workspace_id: String,
}
#[derive(Deserialize)]
struct EditWorkspace {
name: String,
@@ -351,6 +366,7 @@ struct UserWorkspace {
pub username: String,
pub color: Option<String>,
pub operator_settings: Option<Option<serde_json::Value>>,
pub parent_workspace_id: Option<String>,
}
#[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<String> {
}
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<bool> {
@@ -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::<WorkspaceGitSyncSettings>(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::<WorkspaceGitSyncSettings>(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<HashMap<i64, i64>> {
// 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<i64, i64> = 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<HashMap<i64, i64>> {
// 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<i64, i64> = 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::<i64>::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<i64, i64>,
) -> 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<DB>,
Json(nw): Json<CreateWorkspaceFork>,
) -> Result<String> {
// 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<DB>,
+16 -2
View File
@@ -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<bool> {
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))
}
+243
View File
@@ -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 <workspace_name:string>",
"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("<fork_name:string>")
.action(async (opts: GlobalOptions, name: string, silent: boolean) => {
await deleteWorkspaceFork(opts, silent, name);
});
export { forkCommand, deleteForkCommand };
+5 -2
View File
@@ -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 <branch:string>", "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;
+26 -4
View File
@@ -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`);
+46 -12
View File
@@ -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<Workspace | undefined> {
// 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)
+2
View File
@@ -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(() =>
+29
View File
@@ -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", {
+28 -1
View File
@@ -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
@@ -0,0 +1,138 @@
<script lang="ts">
let {
prefix = '',
value = $bindable(''),
placeholder = '',
class: className = '',
...restProps
} = $props()
let inputElement: HTMLInputElement = $state(null!)
let internalValue = $state(prefix + value)
// Update internal value when prop changes
$effect(() => {
internalValue = prefix + value
})
function handleInput(e) {
const newValue = e.target.value
// Ensure the value always starts with the prefix
if (newValue.startsWith(prefix)) {
internalValue = newValue
value = newValue.slice(prefix.length)
} else {
internalValue = prefix
value = ''
// Reset cursor position
if (inputElement) {
inputElement.value = prefix
inputElement.setSelectionRange(prefix.length, prefix.length)
}
}
}
function handleKeyDown(e) {
const cursorPos = e.target.selectionStart
const selectionEnd = e.target.selectionEnd
// Prevent backspace if cursor is at or before prefix end
if (e.key === 'Backspace') {
if (cursorPos <= prefix.length && selectionEnd <= prefix.length) {
e.preventDefault()
} else if (cursorPos <= prefix.length && selectionEnd > prefix.length) {
// If selection spans across prefix, only delete after prefix
e.preventDefault()
const newValue = prefix + internalValue.slice(selectionEnd)
internalValue = newValue
value = newValue.slice(prefix.length)
// Set cursor position after prefix
setTimeout(() => {
inputElement.setSelectionRange(prefix.length, prefix.length)
}, 0)
}
}
// Prevent delete key within prefix
if (e.key === 'Delete' && cursorPos < prefix.length) {
e.preventDefault()
}
// Prevent selecting all (Ctrl+A) from selecting the prefix
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault()
inputElement.setSelectionRange(prefix.length, internalValue.length)
}
}
function handleClick() {
// Prevent cursor from being placed within prefix
if ((inputElement.selectionStart ?? 0) < prefix.length) {
inputElement.setSelectionRange(prefix.length, prefix.length)
}
}
function handleFocus() {
// Ensure cursor starts after prefix when focusing
if ((inputElement.selectionStart ?? 0) < prefix.length) {
inputElement.setSelectionRange(prefix.length, prefix.length)
}
}
function handlePaste(e) {
e.preventDefault()
const pasteData = e.clipboardData.getData('text')
const cursorPos = inputElement.selectionStart ?? 0
const selectionEnd = inputElement.selectionEnd ?? 0
if (cursorPos < prefix.length) {
// Paste at the end of prefix
internalValue = prefix + pasteData + internalValue.slice(prefix.length)
} else {
// Normal paste
internalValue =
internalValue.slice(0, cursorPos) + pasteData + internalValue.slice(selectionEnd)
}
value = internalValue.slice(prefix.length)
// Set cursor position after pasted content
const newCursorPos = Math.max(prefix.length, cursorPos) + pasteData.length
setTimeout(() => {
inputElement.setSelectionRange(newCursorPos, newCursorPos)
}, 0)
}
</script>
<input
bind:this={inputElement}
type="text"
{placeholder}
class="prefixed-input {className}"
value={internalValue}
oninput={handleInput}
onkeydown={handleKeyDown}
onclick={handleClick}
onfocus={handleFocus}
onpaste={handlePaste}
{...restProps}
/>
<style>
.prefixed-input {
/* Default styles - can be overridden by class prop */
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.prefixed-input:focus {
outline: none;
border-color: #4caf50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
}
</style>
@@ -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,
}
}
@@ -294,7 +294,6 @@
{/snippet}
</GitSyncFilterSettings>
{/if}
{#if !repo.isUnsavedConnection}
<div class="flex justify-between items-start">
<!-- Display mode settings as prominent text -->
@@ -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 @@
<span>Are you sure you want to leave this workspace?</span>
</div>
</ConfirmationModal>
{#if $workspaceStore?.startsWith("wm-fork-")}
<ConfirmationModal
open={deleteWorkspaceForkModal}
title="Delete forked workspace"
confirmationText="Remove"
on:canceled={() => {
deleteWorkspaceForkModal = false
}}
on:confirmed={() => {
deleteFork()
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
</div>
</ConfirmationModal>
{/if}
@@ -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)
})
</script>
{#if isForkedWorkspace($workspaceStore ?? '') && !isCollapsed}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<div
class="group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full text-xs"
>
<Building size={12} class="text-tertiary" />
<span class="text-xs text-tertiary"> {parentWorkspace?.name ?? ''} </span>
</div>
{/snippet}
</Menu>
{/if}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-xs"
icon={Building}
label={$workspaceStore ?? ''}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
{#if forkedWorkspace && parentWorkspace}
<div class={isCollapsed ? '' : 'pl-6'}>
<MenuButton
class="!text-xs"
icon={GitFork}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
</div>
{:else}
<MenuButton
class="!text-xs"
icon={Building}
label={$workspaceStore ?? ''}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
{/if}
{/snippet}
{#snippet children({ item })}
<div class="divide-y" role="none">
<div class="py-1">
{#each $userWorkspaces as workspace}
{#each groupedWorkspaces() as { workspace, depth, isForked, parentName }}
<MenuItem
class={twMerge(
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-1.5',
@@ -87,13 +150,37 @@
{item}
>
<div class="flex items-center justify-between min-w-0 w-full">
<div>
<div class="text-primary pl-4 truncate text-left text-[1.2em]">{workspace.name}</div
>
<div
class="text-tertiary font-mono pl-4 text-2xs whitespace-nowrap truncate text-left"
>
{workspace.id}
<div
class={twMerge('flex items-center gap-2 min-w-0', 'pl-4')}
style:padding-left={`${4 + depth * 12}px`}
>
{#if isForked}
<GitFork size={12} class="text-tertiary flex-shrink-0" />
{:else}
<Building size={12} />
{/if}
<div class="min-w-0 flex-1">
<div
class={twMerge(
'truncate text-left text-[1.2em]',
isForked ? 'text-secondary' : 'text-primary'
)}
>
{workspace.name}
</div>
<div
class={twMerge(
'font-mono text-2xs whitespace-nowrap truncate text-left',
isForked ? 'text-tertiary opacity-75' : 'text-tertiary'
)}
>
{workspace.id}
</div>
{#if isForked && parentName}
<div class="text-tertiary text-2xs truncate text-left pl-2 min-h-[1rem]">
Fork of {parentName}
</div>
{/if}
</div>
</div>
{#if workspace.color}
@@ -119,6 +206,19 @@
</a>
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
<a
href="{base}/user/fork_workspace"
class="text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
role="menuitem"
tabindex="-1"
>
<GitFork size={16} />
Fork current workspace
</a>
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem
@@ -0,0 +1,427 @@
<script lang="ts">
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<void> {
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<void> {
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<AIProvider, 'customai'> = $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])
</script>
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace">
{#if isFork}
<div class="flex flex-block gap-2">
<GitFork />
<span class="text-secondary text-l"> Forking </span>
<span class="text-secondary font-bold text-l">
{$workspaceStore}
</span>
</div>
{/if}
<label class="block pb-4 pt-4">
{#if isFork}
<span class="text-secondary text-sm">Fork name</span>
<span class="ml-4 text-tertiary text-xs">Displayable name of the forked workspace</span>
{:else}
<span class="text-secondary text-sm">Workspace name</span>
<span class="ml-4 text-tertiary text-xs">Displayable name</span>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input autofocus type="text" bind:value={name} />
</label>
<label class="block pb-4">
<span class="text-secondary text-sm">Workspace ID</span>
{#if isFork}
<span class="ml-10 text-tertiary text-xs"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="ml-10 text-tertiary text-xs">Slug to uniquely identify your workspace</span>
{/if}
{#if errorId}
<span class="text-red-500 text-xs">{errorId}</span>
{/if}
{#if isFork}
<PrefixedInput
prefix={WM_FORK_PREFIX}
type="text"
bind:value={id}
placeholder="example.com"
class={errorId != '' ? 'input-error' : ''}
/>
{:else}
<input type="text" bind:value={id} class:input-error={errorId != ''} />
{/if}
</label>
<label class="block pb-4">
<span class="text-secondary text-sm">Workspace color</span>
<span class="ml-5 text-tertiary text-xs"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-2">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}<input
class="w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>{/if}
<input
type="text"
class="w-24 text-sm"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
</div>
</label>
{#if !automateUsernameCreation}
<label class="block pb-4">
<span class="text-secondary text-sm">Your username in that workspace</span>
<input type="text" bind:value={username} onkeyup={handleKeyUp} />
{#if errorUser}
<span class="text-red-500 text-xs">{errorUser}</span>
{/if}
</label>
{/if}
{#if !isFork}
<div class="block pb-4">
<label for="ai-key" class="flex flex-col gap-1">
<span class="text-secondary text-sm">
AI key for Windmill AI
<Tooltip>
Find out how it can help you <a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
target="_blank"
rel="noopener noreferrer">in the docs</a
>
</Tooltip>
<span class="text-2xs text-tertiary ml-2">(optional but recommended)</span>
</span>
<div class="pb-2">
<ToggleButtonGroup bind:selected>
{#snippet children({ item })}
<ToggleButton value="openai" label="OpenAI" {item} />
<ToggleButton value="anthropic" label="Anthropic" {item} />
<ToggleButton value="mistral" label="Mistral" {item} />
<ToggleButton value="deepseek" label="DeepSeek" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
</label>
<div class="flex flex-row gap-1 pb-4">
<input
id="ai-key"
type="password"
autocomplete="new-password"
bind:value={aiKey}
onkeyup={handleKeyUp}
/>
<TestAIKey
apiKey={aiKey}
disabled={!aiKey}
aiProvider={selected}
model={AI_PROVIDERS[selected].defaultModels[0]}
/>
</div>
{#if aiKey}
<Toggle
disabled={!aiKey}
bind:checked={codeCompletionEnabled}
options={{ right: 'Enable code completion' }}
/>
{/if}
</div>
<Toggle
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
options={{
right: isCloudHosted()
? `Auto-invite anyone from ${domain}`
: `Auto-invite anyone joining the instance`
}}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-tertiary text-sm mb-4 mt-2">{domain} domain not allowed for auto-invite</div>
{/if}
<div class={'overflow-hidden transition-all ' + (auto_invite ? 'h-36' : 'h-0')}>
<div class="text-xs mb-1 leading-6 pt-2">
Mode <Tooltip>Whether to invite or add users directly to the workspace.</Tooltip>
</div>
<div class="text-xs mb-1 leading-6 pt-2"
>Role <Tooltip>Role of the auto-invited users</Tooltip></div
>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" size="xs" label="Operator" {item} />
<ToggleButton value="developer" size="xs" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between pt-10 gap-1">
<Button variant="border" size="sm" href="{base}/user/workspaces"
>&leftarrow; Back to workspaces</Button
>
<Button
disabled={checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createOrForkWorkspace}
>
{#if isFork}
Fork workspace
{:else}
Create workspace
{/if}
</Button>
</div>
</CenteredModal>
@@ -35,7 +35,7 @@
isInitialSetup = false,
requiresMigration = false,
actions = undefined,
useIndividualBranch = false
useIndividualBranch = false,
} = $props()
// Component state
+4 -2
View File
@@ -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",
+4 -3
View File
@@ -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<Array<UserWorkspace>> = derived(
id: 'admins',
name: 'Admins',
username: 'superadmin',
color: null,
operator_settings: null
color: undefined,
operator_settings: undefined
}
]
} else {
@@ -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<string, UserWorkspace[]>()
const hasChildrenSet = new Set<string>()
// 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<string, UserWorkspace[]>()
// 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
}
@@ -1,352 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import CreateWorkspace from "$lib/components/workspaceSettings/CreateWorkspace.svelte"
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 } 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'
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<void> {
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
}
async function createWorkspace(): Promise<void> {
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<AIProvider, 'customai'> = $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])
</script>
<CenteredModal title="New Workspace">
<label class="block pb-4 pt-4">
<span class="text-secondary text-sm">Workspace name</span>
<span class="ml-4 text-tertiary text-xs">Displayable name</span>
<!-- svelte-ignore a11y_autofocus -->
<input autofocus type="text" bind:value={name} />
</label>
<label class="block pb-4">
<span class="text-secondary text-sm">Workspace ID</span>
<span class="ml-10 text-tertiary text-xs">Slug to uniquely identify your workspace</span>
{#if errorId}
<span class="text-red-500 text-xs">{errorId}</span>
{/if}
<input type="text" bind:value={id} class:input-error={errorId != ''} />
</label>
<label class="block pb-4">
<span class="text-secondary text-sm">Workspace color</span>
<span class="ml-5 text-tertiary text-xs"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-2">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}<input
class="w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>{/if}
<input
type="text"
class="w-24 text-sm"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
</div>
</label>
{#if !automateUsernameCreation}
<label class="block pb-4">
<span class="text-secondary text-sm">Your username in that workspace</span>
<input type="text" bind:value={username} onkeyup={handleKeyUp} />
{#if errorUser}
<span class="text-red-500 text-xs">{errorUser}</span>
{/if}
</label>
{/if}
<div class="block pb-4">
<label for="ai-key" class="flex flex-col gap-1">
<span class="text-secondary text-sm">
AI key for Windmill AI
<Tooltip>
Find out how it can help you <a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
target="_blank"
rel="noopener noreferrer">in the docs</a
>
</Tooltip>
<span class="text-2xs text-tertiary ml-2">(optional but recommended)</span>
</span>
<div class="pb-2">
<ToggleButtonGroup bind:selected>
{#snippet children({ item })}
<ToggleButton value="openai" label="OpenAI" {item} />
<ToggleButton value="anthropic" label="Anthropic" {item} />
<ToggleButton value="mistral" label="Mistral" {item} />
<ToggleButton value="deepseek" label="DeepSeek" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
</label>
<div class="flex flex-row gap-1 pb-4">
<input
id="ai-key"
type="password"
autocomplete="new-password"
bind:value={aiKey}
onkeyup={handleKeyUp}
/>
<TestAIKey
apiKey={aiKey}
disabled={!aiKey}
aiProvider={selected}
model={AI_PROVIDERS[selected].defaultModels[0]}
/>
</div>
{#if aiKey}
<Toggle
disabled={!aiKey}
bind:checked={codeCompletionEnabled}
options={{ right: 'Enable code completion' }}
/>
{/if}
</div>
<Toggle
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
options={{
right: isCloudHosted()
? `Auto-invite anyone from ${domain}`
: `Auto-invite anyone joining the instance`
}}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-tertiary text-sm mb-4 mt-2">{domain} domain not allowed for auto-invite</div>
{/if}
<div class={'overflow-hidden transition-all ' + (auto_invite ? 'h-36' : 'h-0')}>
<div class="text-xs mb-1 leading-6 pt-2">
Mode <Tooltip>Whether to invite or add users directly to the workspace.</Tooltip>
</div>
<div class="text-xs mb-1 leading-6 pt-2"
>Role <Tooltip>Role of the auto-invited users</Tooltip></div
>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" size="xs" label="Operator" {item} />
<ToggleButton value="developer" size="xs" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<div class="flex flex-wrap flex-row justify-between pt-10 gap-1">
<Button variant="border" size="sm" href="{base}/user/workspaces"
>&leftarrow; Back to workspaces</Button
>
<Button
disabled={checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createWorkspace}
>
Create workspace
</Button>
</div>
</CenteredModal>
<CreateWorkspace/>
@@ -0,0 +1,5 @@
<script lang="ts">
import CreateWorkspace from "$lib/components/workspaceSettings/CreateWorkspace.svelte"
</script>
<CreateWorkspace isFork={true}/>
@@ -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.
</p>
{/if}
{#each nonAdminWorkspaces as workspace (workspace.id)}
<label class="block pb-2">
{#each groupedNonAdminWorkspaces as { workspace, depth, isForked, parentName } (workspace.id)}
<label class="block pb-2" style:padding-left={`${depth * 24}px`}>
<button
class="block w-full mx-auto py-1 px-2 rounded-md border
shadow-sm text-sm font-normal mt-1 hover:ring-1 hover:ring-indigo-300"
shadow-sm text-sm font-normal mt-1 hover:ring-1 hover:ring-indigo-300 flex items-center"
on:click={async () => {
speakFriendAndEnterWorkspace(workspace.id)
}}
>
{#if workspace.color}
<span
class="inline-block w-3 h-3 mr-2 rounded-full border border-gray-400"
style="background-color: {workspace.color}"
></span>
{/if}
<span class="font-mono">{workspace.id}</span> - {workspace.name} as
<span class="font-mono">{workspace.username}</span>
{#if workspace['deleted']}
<span class="text-red-500"> (archived)</span>
{#if isForked}
<GitFork size={12} class="text-tertiary mr-2 flex-shrink-0" />
{/if}
<div class="flex-1 text-left">
{#if workspace.color}
<span
class="inline-block w-3 h-3 mr-2 rounded-full border border-gray-400"
style="background-color: {workspace.color}"
></span>
{/if}
<span class="font-mono" class:text-secondary={isForked}>{workspace.id}</span> -
<span class:text-secondary={isForked}>{workspace.name}</span>
as
<span class="font-mono" class:text-secondary={isForked}>{workspace.username}</span>
{#if workspace['deleted']}
<span class="text-red-500"> (archived)</span>
{/if}
{#if isForked && parentName}
<div class="text-tertiary text-xs mt-1">
Fork of {parentName}
</div>
{/if}
</div>
</button>
{#if $superadmin && workspace['deleted']}
<Button
@@ -246,11 +271,13 @@
</div>
{/if}
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
<h2 class="mt-6 mb-4">Invites to join a Workspace</h2>
{#if invites.length == 0}
{#if nonForkInvites.length == 0}
<p class="text-sm text-tertiary mt-2"> You don't have new invites at the moment. </p>
{/if}
{#each invites as invite}
{#each nonForkInvites as invite}
<div
class="w-full mx-auto py-1 px-2 rounded-md border shadow-sm
text-sm mt-1 flex flex-row justify-between items-center"
@@ -288,6 +315,74 @@
</div>
</div>
{/each}
{#if showAllForks}
{@const allWorkspacesList = workspaces || []}
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
<h2 class="mt-6 mb-4">Forks of the workspaces you're in</h2>
{#if filteredInvites.length == 0}
<p class="text-sm text-tertiary mt-2"> There isn't anything here </p>
{/if}
{#each filteredInvites as invite}
{@const inviteWorkspace = allWorkspacesList.find((w) => w.id === invite.workspace_id)}
<div
class="w-full mx-auto py-1 px-2 rounded-md border shadow-sm
text-sm mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<div class="flex items-center gap-2">
{#if inviteWorkspace?.parent_workspace_id}
<GitFork size={12} class="text-tertiary flex-shrink-0" />
{/if}
<span class="font-mono font-semibold">{invite.workspace_id}</span>
</div>
{#if invite.is_admin}
<span class="text-sm">as an admin</span>
{:else if invite.operator}
<span class="text-sm">as an operator</span>
{/if}
{#if invite.parent_workspace_id}
<div class="text-tertiary text-xs mt-1">
Fork of {invite.parent_workspace_id}
</div>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<a
class="font-bold p-1"
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
? `&rd=${encodeURIComponent(rd)}`
: ''}"
>
Accept
</a>
<button
class="text-red-700 font-bold p-1"
on:click={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
>
Decline
</button>
</div>
</div>
{/each}
{/if}
{#if workspaces}
<div class="flex flex-row-reverse pt-4 pb-2">
<Toggle
bind:checked={showAllForks}
options={{ right: 'Show workspace forks' }}
/>
</div>
{/if}
<div class="flex justify-between items-center mt-10 flex-wrap gap-2">
{#if $superadmin}
<Button