mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
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:
+8
-2
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+46
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+46
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+271
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+46
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+95
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+14
@@ -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 @@
|
||||
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"
|
||||
>← 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
>← 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
|
||||
|
||||
Reference in New Issue
Block a user