mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add dev workspaces paired with a lockable prod workspace (#9793)
* feat: add dev workspaces paired with a lockable prod workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: gate dev-workspace prod-lock on admin and prevent attach cycles Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: redirect locked-prod edits into the dev workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: make dev-workspace settings tab available on CE (was EE-gated) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: lock prod against forking too and funnel edits to the dev workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: open dev item page on edit and tailor dev-workspace lock messages Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: prevent nested dev workspaces and hide dev option when one exists Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: drop the redundant already-has-dev hint on the fork form Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: badge dev workspaces and sort them ahead of forks in the tree/switcher Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: label dev workspaces as 'Dev workspace of X' instead of 'Fork of X' Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: label edit as 'Edit in <dev>', cover editor headers, auto-expand dev in tree Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: split prod lock into separate block-deploy and prevent-forking toggles Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: make resources/variables workspace-specific from compare page Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: steer AI-chat sessions to the dev workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: refine session fork options and lock guidance for dev/prod Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: session picker reads prod's real rules, default to current ws Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: copy members into forks and clarify dev-workspace root labeling Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: place the workspace id field under the fork name Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address dev-workspace review findings and harden fork detection Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: regenerate sqlx offline cache Restores entries dropped during the origin/main merge and adds the dev-workspace queries (is_dev_workspace, ws_specific, has_parent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address second-round dev-workspace review findings Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address Pi and Codex review findings on dev-workspace endpoints Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: gate locked-dev git-branch fork on admin and validate ws_specific path Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: clear prod dev-lock when deleting an attached dev workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: consolidate dev-workspace migration and scope all-group join to attach Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: restore dev-workspace CHECK into consolidated migration and scope all-group join Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: drop copy_members from the dev-workspace attach path Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: dev-workspace lifecycle/auth fixes from Codex review round Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: explicit create-in-other for workspace-specific items Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: make create-in-other strictly create-only (never overwrite target) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: return 403 (not 401) for dev-workspace permission denials Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: allow attaching a same-family fork as a dev workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: emphasize the go-to-dev action in the no-direct-deploy alert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: seed a resource's linked variables when creating it in the other workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: judge workspace deploy/fork locks against the user's identity in that workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: clarify create-in help text in workspace-specific panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: admin-gate dev-workspace creation and harden lock/seed edges Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: preserve a staged fork's source on picker create-mode re-entry Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: clear dev flag on archive and check dev existence server-side Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: make create-in-other atomically create-only via direct create Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: create-only resource insert, ws-specific list scopes, archive lock guard Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: reserve the dev_workspace_lock protection-rule name from the public API Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: reattach create_protection_rule doc comment to its function Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: make dev-archive pairing teardown atomic with the archive Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: follow deploy_to on root rename; show dev pairing to non-member prod admins Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: copy creator metadata on fork; invalidate fork routing cache on rename Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: accept g/ paths in set_ws_specific; gate copy_members to dev workspaces Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH del AS (\n DELETE FROM v2_job_debounce_batch\n WHERE consumed_at IS NOT NULL AND consumed_at < now() - interval '10 minutes'\n RETURNING 1\n ) SELECT count(*) FROM del",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0035bf99ce6fc00c7338bebfeb7e79bb9e7bc3d216b84279dee0018603965941"
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM asset\n WHERE (workspace_id, path, kind) IN (\n SELECT workspace_id, path, kind FROM (\n SELECT a.workspace_id, a.path, a.kind, a.usage_kind, ROW_NUMBER() OVER (\n PARTITION BY a.workspace_id, a.path, a.kind\n ORDER BY a.created_at DESC\n ) as rn,\n limits.max_n\n FROM asset a\n INNER JOIN (\n SELECT * FROM UNNEST(\n $1::varchar[], \n $2::varchar[], \n $3::asset_kind[],\n $4::int[]\n ) AS t(workspace_id, path, kind, max_n)\n ) limits\n ON a.workspace_id = limits.workspace_id \n AND a.path = limits.path \n AND a.kind = limits.kind\n WHERE a.usage_kind = 'job'\n ) ranked\n WHERE rn > max_n\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"VarcharArray",
|
||||
"VarcharArray",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind[]",
|
||||
"kind": {
|
||||
"Array": {
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
|
||||
"query": "UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e"
|
||||
"hash": "0621faf69b1ef866a95f6310c9651875df409a2d2d72ada629bd71e8abdbbf8b"
|
||||
}
|
||||
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n CASE WHEN q.running\n THEN $3::text::jsonb\n ELSE $4::text::jsonb\n END,\n s.flow_status,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($5, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "x",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "165abf847773c7fe718fa6c832b259bf2dc531bc468d2ab27f76c9c653be0b9a"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT (parent_workspace_id IS NOT NULL) AS \"has_parent!\", is_dev_workspace\n FROM workspace WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_parent!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_dev_workspace",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "16c7838ecfcea5fd231f2a4766f691a9a11ca7bb9797b81444419d6a72883531"
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"job"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1c5caaaa86e3488549cad179e992172315be5d53dcc266d713da01fd27f310b6"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (workspace_id, name)\n DO UPDATE SET rules = EXCLUDED.rules,\n bypass_groups = EXCLUDED.bypass_groups,\n bypass_users = EXCLUDED.bypass_users\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1cd7c77e7a6a5c13c4ca521098bb07c1d805d21899fe0ebac22132b248ffd242"
|
||||
}
|
||||
+4
-2
@@ -38,7 +38,8 @@
|
||||
"google",
|
||||
"ci_test",
|
||||
"github",
|
||||
"azure"
|
||||
"azure",
|
||||
"asset"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -75,7 +76,8 @@
|
||||
"google",
|
||||
"ci_test",
|
||||
"github",
|
||||
"azure"
|
||||
"azure",
|
||||
"asset"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH del AS (\n DELETE FROM v2_job_debounce_batch\n WHERE consumed_at IS NOT NULL AND consumed_at < now() - interval '10 minutes'\n RETURNING 1\n ) SELECT count(*) as \"c!\" FROM del",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "c!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "299b94a7972443267dd664c178a1704d195a7fc0d4e66e1014a18398e3a294f4"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5\n FROM workspace WHERE id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "29eb2c40e13d6e1ff7c37a05ab107829242f015a331bf15600986be3963878ee"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
|
||||
"query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM ws_specific ws\n WHERE ws.path = workspace_diff.path\n AND ws.item_kind = workspace_diff.kind\n AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id)\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,5 +55,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1"
|
||||
"hash": "2ec9f88ad80d192a2066764222fdfed7c553de4df8e876ff8589738dea93d0cf"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ws_specific (workspace_id, item_kind, path)\n SELECT $1::varchar, 'resource', $2::varchar\n WHERE EXISTS (SELECT 1 FROM resource WHERE workspace_id = $1::varchar AND path = $2::varchar)\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3020d5477b4822f1b0e3b2e4f2947e24754b919f7ee1aa2e7c1cb8c36e9e94b1"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT (parent_workspace_id IS NOT NULL) AS \"is_fork!\" FROM workspace WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_fork!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "34b94da001dafbbe66b3b945e71e33128bbc0dcdba9850ccbe4dc278836513bd"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (path) path AS \"path!\", content AS \"content!\"\n FROM script\n WHERE workspace_id = $1\n AND auto_kind = 'pipeline'\n AND archived = false\n AND deleted = false\n AND ($2::text IS NULL OR path LIKE $2)\n ORDER BY path, created_at DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "content!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "394e2598880aff8a7f4ee05c3fe748be58b6381f5fae5619d8376daefc3b21db"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40bcbfdcae9842c7919eb6dcfe44d844508304700b292059b24c3f74454a7cca"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id)\n VALUES ($1, $2, $3, $4)",
|
||||
"query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace)\n VALUES ($1, $2, $3, $4, $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,10 +8,11 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e947340c7d40f6b9536e7a24fa84bee393e24f6f39f67e0b9e20e9cb8f04244c"
|
||||
"hash": "41ce08f45b09532cbab6fb039703f08d6476d00d85a02ea3b19aa559b9ec1056"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n SELECT $1, email, username, is_admin FROM usr\n WHERE workspace_id = $3 AND email = $2\n ",
|
||||
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n SELECT $1, email, username, is_admin FROM usr\n WHERE workspace_id = $3 AND email = $2\n ON CONFLICT DO NOTHING\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1a9f2ed5045016a3953db335957b26f41efc8a3cad7af7bc8fe97df6a5bf5078"
|
||||
"hash": "42a0ba479ff164cc190c350927e13902ed94816142faf15446b4b9f19c3097d7"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO runnable_settings (hash, debouncing_settings, concurrency_settings)\n VALUES ($1, $2, $3)\n ON CONFLICT (hash)\n DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "451d9cde90d14071e21ffb5f615052b7ba7fc315fc301ed5c0ff50d9a3ab0d4a"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace SET is_dev_workspace = false WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4e4efea2b8d3b0bd2717b27d1895b00e8dba07aa817ee9bfbe2271d41c9b411a"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5190528997a879981a87420ddf3d28c978c8a5876f5c1ac1613391e86ffb550f"
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH mine AS (\n SELECT debounce_batch, consumed_by FROM v2_job_debounce_batch WHERE id = $1\n ), claim_self AS (\n UPDATE v2_job_debounce_batch SET consumed_at = now(), consumed_by = $1\n WHERE id = $1 AND consumed_at IS NULL\n RETURNING debounce_batch\n ), claim_rest AS (\n UPDATE v2_job_debounce_batch SET consumed_at = now(), consumed_by = $1\n WHERE debounce_batch = (SELECT debounce_batch FROM claim_self)\n AND id <> $1 AND consumed_at IS NULL\n RETURNING id\n )\n SELECT\n EXISTS (SELECT 1 FROM mine) AS \"had_row!\",\n (SELECT debounce_batch FROM claim_self) AS claimed_batch,\n (SELECT consumed_by FROM mine) AS prev_consumed_by,\n ARRAY(SELECT id FROM claim_rest) AS \"claimed_ids!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "had_row!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "claimed_batch",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "prev_consumed_by",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "claimed_ids!",
|
||||
"type_info": "UuidArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "57f375e89d63ac118c5c6767487af401e6d800488ade04facb6af14578c30a89"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -30,11 +30,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_dev_workspace",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -50,9 +55,10 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6"
|
||||
"hash": "63d323be5cacb7a02283d7d82c79bc408b9a33a228b80e88ec3f6432944a7c19"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job\n SET args = CASE\n WHEN args ? 'partition'\n THEN $1 || jsonb_build_object('partition', args -> 'partition')\n ELSE $1\n END,\n preprocessed = TRUE\n WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "651fc12e1b971d4fd57c98a7a7efbd503d8dea799545e9cb96574d5c6020b90b"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH del AS (\n DELETE FROM v2_job_debounce_batch\n WHERE consumed_at IS NOT NULL AND consumed_at < now() - interval '1 hour'\n RETURNING 1\n ) SELECT count(*) FROM del",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "70f01b322765442de8888b6d9b79984da751f9ae10c8b15ada925bd371501e18"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT runnable_path AS \"runnable_path!\", kind::text AS \"kind!\"\n FROM v2_job\n WHERE workspace_id = $1 AND trigger_kind = 'asset'\n ORDER BY runnable_path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "runnable_path!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "754b98335e8776565d63267b395013649adacf348e3a815e991b4463b1711afc"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT (parent_workspace_id IS NOT NULL) AS \"has_parent!\" FROM workspace WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_parent!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7853a596a01884070455e68bbf8ab2afa79a5b5b8521f68ab19f0470f0265c7c"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "78bb75578a880715fb482445883e0e762f289b7695f29bfd44fa23323c7e8523"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "parent_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7b950d49cb1cc7f9bf8032c9e7655c49027337a9eca1f1ded13dbae6475e3376"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO background_task_state (name, value)\n SELECT $1, jsonb_build_object(\n 'last_xmin', txid_snapshot_xmin(txid_current_snapshot())::bigint,\n 'last_ts', '1970-01-01T00:00:00+00:00')\n WHERE NOT EXISTS (SELECT 1 FROM global_settings WHERE name = $2)\n ON CONFLICT (name) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8192986cd6106ed060b3d68dbc21e5bb34f5e68a5c6ac455a9d423188af77b23"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace SELECT $1, $2, owner, false, premium FROM workspace WHERE id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "82185eb02e03e3dd1a4b5a3f22c3b60169989703ee33d14ab348301885c9d745"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "parent_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "deleted",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8288b3916022adcbb28da2a0e4329cba84759b8a590546aea8f0ddab4f861c38"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT q.runnable_settings_handle\n FROM v2_job j JOIN v2_job_queue q ON q.id = j.id\n WHERE j.workspace_id = $1 AND j.runnable_path = $2\n AND j.trigger_kind = 'asset'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "runnable_settings_handle",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "82dcaf94ffe43da1c8c7de2a3478b4919c4f1dbf1972d04664a730cefc0594e2"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "84cbf9623a989dc16be1f8681c95eed9ae3e8d9c1552f396b6773767087bcfec"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ws_specific (workspace_id, item_kind, path)\n SELECT $1::varchar, 'variable', $2::varchar\n WHERE EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1::varchar AND path = $2::varchar)\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "90d21ee2276b41a4dd3dd8ed12f36e00b8d4fc27a2d72b74dff2dace900db75c"
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT status = 'success' AS \"success!\"\n FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.id != $4\n ORDER BY created_at DESC\n LIMIT $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "success!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "910b9b8afb3df5e437e43ff4adc97d0a03f6c26f0b14fc92900f7600e70a7a8b"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a22611ed3b7a6caa76a218454494c96e1371956bb303eb8d3e3268139cd8fce0"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END\n FROM workspace WHERE id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a981f9b6424969e5fd72fb18c20e2910a138a848f3f6674dec58053572682077"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a999a5cf7b481d852222311a34959f38114926063718668907587d1b80dfc75e"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT path AS \"path!\"\n FROM script\n WHERE workspace_id = $1\n AND auto_kind = 'pipeline'\n AND archived = false\n AND deleted = false\n AND ($2::text IS NULL OR path LIKE $2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a9a99880d870266f474878dd6ef541df988da527d30f663ef6f764f0c3d70d4b"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_diff SET has_changes = NULL\n WHERE path = $2 AND kind = $3\n AND ($1 IN (source_workspace_id, fork_workspace_id))",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bf6e1abbab6bdf0e67eefa5f807a26b06d24a7b3e292ae4be97fbbb197d468db"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM workspace\n WHERE id = $1 AND parent_workspace_id = $2 AND is_dev_workspace\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bfa4fb5998dec9baf89a2d950186e2dc9c0aecf7831ce66082a40f17cc5ac0de"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO v2_job_debounce_batch (id, debounce_batch, consumed_at) VALUES\n ($1, nextval('debounce_batch_seq'), now() - interval '20 minutes'),\n ($2, nextval('debounce_batch_seq'), now() - interval '1 minute'),\n ($3, nextval('debounce_batch_seq'), NULL)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c886e8af0fc8a3999a813371855c0053571e79960280f0714616d13a456d7bed"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH ids AS (\n SELECT id as job_id FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )\n ) SELECT args->>$2 FROM ids LEFT JOIN v2_job ON v2_job.id = ids.job_id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d0e826043e5a129ae6768c274c67b6254ff6c5fd450ecdab886a3183a894d266"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2\n AND NOT EXISTS (\n SELECT 1 FROM workspace\n WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d3ce4e7f3dd10548197734a0cf38c644f70be809dcfe7d973147962530c601d5"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d74dfaa8cb9fca89c2e21f810cd45ff7b595c7c50f5e6ff2733eded6ef544996"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d8f234765e2de89f780937a505c236c5be9b36b4df95735fedb44ed944606ac8"
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n kind AS \"kind!: AssetKind\",\n path AS \"path!\"\n FROM asset\n WHERE workspace_id = $1\n AND usage_kind = 'script'\n AND usage_path = $2\n AND usage_access_type IN ('w', 'rw')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!: AssetKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "de06f44bad94710f14e9be4c0a6e6080e3c4faae5052500b93cc24b6fe556f2b"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef"
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT status = 'success' AS \"success!\",\n result AS \"result: Json<Box<RawValue>>\",\n started_at AS \"started_at!\"FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.id != $4\n ORDER BY created_at DESC\n LIMIT $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "success!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "result: Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "started_at!",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e6a8ddfd74ebab55ede5989fd7d09b246b6f84e49c659bc8e2c7b66cfec6d976"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT concurrency_settings, debouncing_settings FROM runnable_settings WHERE hash = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "concurrency_settings",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "debouncing_settings",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ebbbd069e0f33be9609604025d159fe1ecbefc2e9c11f7c4900b7121d4367e01"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f1f79711f131ff4116489153db1b71e528c66fab1508e5c41b63bc5b9077a08c"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = $2 AND path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fa04b3660f1f90c39c3d12f39c51df8f25a3bdae60aeaed313612cb858040c8d"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE workspace DROP CONSTRAINT IF EXISTS workspace_dev_requires_parent;
|
||||
DROP INDEX IF EXISTS workspace_canonical_dev_idx;
|
||||
ALTER TABLE workspace DROP COLUMN is_dev_workspace;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- A dev workspace is a fork (parent_workspace_id set) that is the standing editable
|
||||
-- environment paired with its parent ("prod"), as opposed to a throwaway fork.
|
||||
ALTER TABLE workspace ADD COLUMN is_dev_workspace BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- At most one active canonical dev workspace per parent (one editable source per prod).
|
||||
-- Excludes soft-deleted (archived) workspaces so a new dev can replace an archived one.
|
||||
CREATE UNIQUE INDEX workspace_canonical_dev_idx ON workspace (parent_workspace_id)
|
||||
WHERE is_dev_workspace AND deleted = false;
|
||||
|
||||
-- A dev workspace is a fork, so it must have a parent. Enforce the invariant at the schema level so
|
||||
-- no path (or manual write) can persist a "root dev workspace". No backfill needed: the column is
|
||||
-- added above with default false, so no existing row can violate this at creation time.
|
||||
ALTER TABLE workspace ADD CONSTRAINT workspace_dev_requires_parent
|
||||
CHECK (NOT is_dev_workspace OR parent_workspace_id IS NOT NULL);
|
||||
@@ -182,7 +182,7 @@ windmill_migrations: name(text), created_at(ts)
|
||||
worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[])
|
||||
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char)
|
||||
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool)
|
||||
FK: (parent_workspace_id) -> workspace(id)
|
||||
workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts)
|
||||
workspace_diff: source_workspace_id(char), fork_workspace_id(char), path(char), kind(char), ahead(int), behind(int), has_changes(bool), exists_in_source(bool), exists_in_fork(bool)
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed};
|
||||
use windmill_api_auth::{
|
||||
build_scope_path_predicate, check_scopes, require_devops_role, require_is_writer,
|
||||
require_super_admin, ApiAuthed,
|
||||
};
|
||||
use windmill_api_users::users::WorkspaceInvite;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||||
@@ -40,9 +43,10 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_user_against_rule, get_datatable_resource_from_db_unchecked, validate_fork_workspace_id,
|
||||
DataTable, DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind,
|
||||
ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings,
|
||||
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked,
|
||||
validate_dev_workspace_id, validate_fork_workspace_id, DataTable, DataTableCatalogResourceType,
|
||||
DataTableForkBehavior, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult,
|
||||
WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -150,6 +154,9 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/leave", post(leave_workspace))
|
||||
.route("/get_workspace_name", get(get_workspace_name))
|
||||
.route("/create_fork", post(create_workspace_fork))
|
||||
.route("/attach_dev_workspace", post(attach_dev_workspace))
|
||||
.route("/detach_dev_workspace", post(detach_dev_workspace))
|
||||
.route("/get_dev_workspace", get(get_dev_workspace))
|
||||
.route("/change_workspace_name", post(change_workspace_name))
|
||||
.route("/change_workspace_color", post(change_workspace_color))
|
||||
.route(
|
||||
@@ -191,6 +198,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/prune_versions", post(prune_versions))
|
||||
.route("/list_ws_specific", get(list_ws_specific))
|
||||
.route("/list_ws_specific_versions", get(list_ws_specific_versions))
|
||||
.route("/set_ws_specific", post(set_ws_specific))
|
||||
}
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -441,6 +449,20 @@ struct CreateWorkspaceFork {
|
||||
/// forked workspace's datatable config to point to the new database.
|
||||
#[serde(default)]
|
||||
forked_datatables: Vec<ForkedDatatableInfo>,
|
||||
/// Create the fork as a persistent dev workspace: the id is not required to carry the
|
||||
/// `wm-fork-` prefix, and at most one dev workspace may exist per parent.
|
||||
#[serde(default)]
|
||||
is_dev_workspace: bool,
|
||||
/// When creating a dev workspace, lock the parent ("prod") against direct deployment and/or
|
||||
/// ad-hoc forking, so edits are funneled through the dev workspace.
|
||||
#[serde(default)]
|
||||
lock_prod_deploy: bool,
|
||||
#[serde(default)]
|
||||
lock_prod_forking: bool,
|
||||
/// Copy the parent's members (usr rows + group memberships) into the fork so
|
||||
/// the team can work in it. Defaults off; the dev-workspace UI defaults it on.
|
||||
#[serde(default)]
|
||||
copy_members: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -469,6 +491,7 @@ struct UserWorkspace {
|
||||
pub color: Option<String>,
|
||||
pub operator_settings: Option<Option<serde_json::Value>>,
|
||||
pub parent_workspace_id: Option<String>,
|
||||
pub is_dev_workspace: bool,
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
@@ -637,6 +660,34 @@ async fn exists_workspace(
|
||||
Ok(Json(exists))
|
||||
}
|
||||
|
||||
/// Whether this workspace already has an active canonical dev workspace. The create-fork UI can't
|
||||
/// rely on the caller's workspace list to decide this — a dev paired to this prod may exist that the
|
||||
/// caller isn't a member of — so it asks the server, which sees all children.
|
||||
#[derive(Serialize)]
|
||||
struct DevWorkspaceInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace
|
||||
/// settings tab can't rely on the caller's workspace list — a dev paired to this prod may exist that
|
||||
/// the caller isn't a member of — so they ask the server, which sees all children. Returns its id/name
|
||||
/// so a prod admin who isn't a dev member can still see the pairing and detach it.
|
||||
async fn get_dev_workspace(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Option<DevWorkspaceInfo>> {
|
||||
let dev = sqlx::query_as!(
|
||||
DevWorkspaceInfo,
|
||||
"SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
Ok(Json(dev))
|
||||
}
|
||||
|
||||
async fn list_workspaces(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -3610,6 +3661,7 @@ async fn user_workspaces(
|
||||
let workspaces = sqlx::query_as!(
|
||||
UserWorkspace,
|
||||
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
|
||||
workspace.is_dev_workspace,
|
||||
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
|
||||
usr.disabled
|
||||
FROM workspace
|
||||
@@ -4378,6 +4430,28 @@ async fn clone_groups(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the source workspace's members (the `usr` rows, carrying each member's role) into the
|
||||
/// target so a fork/dev can be a shared environment. Idempotent — skips members the target already
|
||||
/// has. Group memberships are not handled here: the sole caller is the create-fork path, where
|
||||
/// `clone_groups` already copies the source's full group structure (including `all` membership).
|
||||
async fn copy_workspace_members(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)
|
||||
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via
|
||||
FROM usr WHERE workspace_id = $2
|
||||
ON CONFLICT DO NOTHING",
|
||||
target_workspace_id,
|
||||
source_workspace_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clone_resource_types(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source_workspace_id: &str,
|
||||
@@ -4927,7 +5001,26 @@ async fn create_workspace_fork_branch(
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
validate_fork_workspace_id(&nw.id)?;
|
||||
// Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and
|
||||
// validates up front; it does NOT create the workspace row. The caller follows up with
|
||||
// `create_workspace_fork`, which inserts the row and applies the dev designation + prod lock +
|
||||
// member copy. So the dev/lock/copy_members fields here are validated only — they are acted on by
|
||||
// that second call. Validating early lets a bad request fail before any branch is created.
|
||||
if nw.is_dev_workspace {
|
||||
validate_dev_workspace_id(&nw.id)?;
|
||||
ensure_dev_parent_is_root(&db, &w_id).await?;
|
||||
// Reject before creating any git branch if the parent already has a dev workspace,
|
||||
// otherwise the deferred branch-creation job leaves a dangling branch on the synced repos.
|
||||
ensure_no_existing_dev_workspace(&db, &w_id).await?;
|
||||
// Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking the
|
||||
// parent mutates its protection rules), so require admin of the parent regardless of the lock
|
||||
// flags — mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker
|
||||
// could claim the dev slot. Enforced in this first phase too so the request fails before any
|
||||
// git branch is created rather than leaving dangling branches.
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
} else {
|
||||
validate_fork_workspace_id(&nw.id)?;
|
||||
}
|
||||
|
||||
// Fail before creating any git branch so a name conflict doesn't leave a
|
||||
// dangling branch on the synced repos.
|
||||
@@ -5072,7 +5165,11 @@ async fn create_workspace_fork(
|
||||
)));
|
||||
}
|
||||
|
||||
validate_fork_workspace_id(&nw.id)?;
|
||||
if nw.is_dev_workspace {
|
||||
validate_dev_workspace_id(&nw.id)?;
|
||||
} else {
|
||||
validate_fork_workspace_id(&nw.id)?;
|
||||
}
|
||||
// Check the id conflict before the CE workspace-count limit so that
|
||||
// re-using a taken (possibly archived) fork id reports the actual
|
||||
// conflict instead of a misleading "maximum number of workspaces" error.
|
||||
@@ -5098,18 +5195,29 @@ async fn create_workspace_fork(
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
if nw.is_dev_workspace {
|
||||
ensure_dev_parent_is_root(&db, &parent_workspace_id).await?;
|
||||
// Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod
|
||||
// mutates its protection rules), so require admin of the parent regardless of the lock flags —
|
||||
// mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could
|
||||
// claim the dev slot (and, without member copy, prod admins might not even see it to detach).
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
ensure_no_existing_dev_workspace(&db, &parent_workspace_id).await?;
|
||||
}
|
||||
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
let forked_id = nw.id;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace
|
||||
(id, name, owner, parent_workspace_id)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
(id, name, owner, parent_workspace_id, is_dev_workspace)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
forked_id,
|
||||
nw.name,
|
||||
authed.email,
|
||||
parent_workspace_id,
|
||||
nw.is_dev_workspace,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -5124,11 +5232,24 @@ async fn create_workspace_fork(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Optionally bring the parent's members into the fork (a shared dev env). Dev-only: it's part of the
|
||||
// dev-workspace feature (and the frontend only offers it there), so the backend enforces it rather
|
||||
// than trusting the client — copying the parent's whole team into an ordinary throwaway fork isn't
|
||||
// intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before
|
||||
// the explicit creator insert below so the creator (a parent member) is copied with full metadata
|
||||
// (operator/role/is_service_account/added_via), not the bare row the insert alone would leave.
|
||||
if nw.copy_members && nw.is_dev_workspace {
|
||||
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?;
|
||||
}
|
||||
|
||||
// Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent
|
||||
// member). No-op when copy_members already brought their full row.
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr
|
||||
(workspace_id, email, username, is_admin)
|
||||
SELECT $1, email, username, is_admin FROM usr
|
||||
WHERE workspace_id = $3 AND email = $2
|
||||
ON CONFLICT DO NOTHING
|
||||
",
|
||||
forked_id,
|
||||
authed.email,
|
||||
@@ -5151,6 +5272,18 @@ async fn create_workspace_fork(
|
||||
apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?;
|
||||
}
|
||||
|
||||
// Lock the parent ("prod") so edits are funneled through this dev workspace.
|
||||
let locked_prod = nw.is_dev_workspace && (nw.lock_prod_deploy || nw.lock_prod_forking);
|
||||
if locked_prod {
|
||||
lock_prod_workspace(
|
||||
&mut tx,
|
||||
&parent_workspace_id,
|
||||
nw.lock_prod_deploy,
|
||||
nw.lock_prod_forking,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -5163,9 +5296,235 @@ async fn create_workspace_fork(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if locked_prod {
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&parent_workspace_id);
|
||||
}
|
||||
|
||||
Ok(format!("Created forked workspace {}", &forked_id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AttachDevWorkspace {
|
||||
dev_workspace_id: String,
|
||||
#[serde(default)]
|
||||
lock_prod_deploy: bool,
|
||||
#[serde(default)]
|
||||
lock_prod_forking: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DetachDevWorkspace {
|
||||
dev_workspace_id: String,
|
||||
}
|
||||
|
||||
/// Pair an existing standalone workspace to this workspace ("prod") as its dev workspace, without
|
||||
/// cloning any data (both already exist). Sets the dev's parent + deploy_to to prod and, optionally,
|
||||
/// locks prod against direct deployment.
|
||||
async fn attach_dev_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(prod_w_id): Path<String>,
|
||||
Json(req): Json<AttachDevWorkspace>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(
|
||||
"Dev workspaces are not available on app.windmill.dev".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let dev_w_id = req.dev_workspace_id;
|
||||
if dev_w_id == prod_w_id {
|
||||
return Err(Error::BadRequest(
|
||||
"A workspace cannot be its own dev workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// The id is interpolated into a `wm-fork/<branch>/<id>` branch name like any fork.
|
||||
validate_dev_workspace_id(&dev_w_id)?;
|
||||
|
||||
let dev = sqlx::query!(
|
||||
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
|
||||
&dev_w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?;
|
||||
|
||||
if dev.deleted {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is archived",
|
||||
dev_w_id
|
||||
)));
|
||||
}
|
||||
// A candidate that already belongs to a DIFFERENT parent can't be attached. A candidate already
|
||||
// parented to this prod is allowed: it's the recovery path after renaming a dev workspace (the
|
||||
// rename keeps the parent but drops the dev flag), and re-designating an existing fork of this
|
||||
// prod as its dev.
|
||||
if dev
|
||||
.parent_workspace_id
|
||||
.as_deref()
|
||||
.is_some_and(|p| p != prod_w_id)
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is already a fork or dev workspace of another workspace",
|
||||
dev_w_id
|
||||
)));
|
||||
}
|
||||
// The candidate can't itself be a prod with its own dev workspace (no nested dev chains).
|
||||
ensure_no_existing_dev_workspace(&db, &dev_w_id).await?;
|
||||
|
||||
// Prod must be a root workspace, otherwise attaching could form a parent<->child cycle (e.g.
|
||||
// attaching A as the dev of B when B is already the dev of A), which breaks hierarchy traversal.
|
||||
let prod_has_parent = sqlx::query_scalar!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#,
|
||||
&prod_w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Workspace {} not found", prod_w_id)))?;
|
||||
if prod_has_parent {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is itself a fork or dev workspace and cannot be a prod workspace",
|
||||
prod_w_id
|
||||
)));
|
||||
}
|
||||
|
||||
// The caller must be admin of the dev workspace too (or a superadmin).
|
||||
let is_admin_of_dev = sqlx::query_scalar!(
|
||||
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
&dev_w_id,
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_admin_of_dev && !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"Attaching workspace '{dev_w_id}' as a dev requires being an admin of it (or a superadmin)"
|
||||
)));
|
||||
}
|
||||
|
||||
ensure_no_existing_dev_workspace(&db, &prod_w_id).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2",
|
||||
&prod_w_id,
|
||||
&dev_w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2",
|
||||
&prod_w_id,
|
||||
&dev_w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if req.lock_prod_deploy || req.lock_prod_forking {
|
||||
lock_prod_workspace(
|
||||
&mut tx,
|
||||
&prod_w_id,
|
||||
req.lock_prod_deploy,
|
||||
req.lock_prod_forking,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.attach_dev_workspace",
|
||||
ActionKind::Update,
|
||||
&prod_w_id,
|
||||
Some(&dev_w_id),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// The dev workspace's parent just changed (none -> prod); drop its cached fork->parent mapping
|
||||
// so per-workspace job tags route to the prod family immediately rather than after the TTL.
|
||||
windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id);
|
||||
|
||||
if req.lock_prod_deploy || req.lock_prod_forking {
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Attached {} as dev workspace of {}",
|
||||
dev_w_id, prod_w_id
|
||||
))
|
||||
}
|
||||
|
||||
/// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the
|
||||
/// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork).
|
||||
async fn detach_dev_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(prod_w_id): Path<String>,
|
||||
Json(req): Json<DetachDevWorkspace>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let dev_w_id = req.dev_workspace_id;
|
||||
let is_dev_of_prod = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(
|
||||
SELECT 1 FROM workspace
|
||||
WHERE id = $1 AND parent_workspace_id = $2 AND is_dev_workspace
|
||||
)"#,
|
||||
&dev_w_id,
|
||||
&prod_w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_dev_of_prod {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{} is not the dev workspace of {}",
|
||||
dev_w_id, prod_w_id
|
||||
)));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET is_dev_workspace = false WHERE id = $1",
|
||||
&dev_w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Only one dev per prod, so detaching it means prod no longer has a dev: drop the lock rule.
|
||||
sqlx::query!(
|
||||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||||
&prod_w_id,
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.detach_dev_workspace",
|
||||
ActionKind::Update,
|
||||
&prod_w_id,
|
||||
Some(&dev_w_id),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id);
|
||||
|
||||
Ok(format!(
|
||||
"Detached dev workspace {} from {}",
|
||||
dev_w_id, prod_w_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn edit_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -5205,6 +5564,10 @@ pub(crate) async fn archive_workspace_impl(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
username: &str,
|
||||
// When archiving a dev workspace, its parent prod. The pairing teardown (clear is_dev + drop the
|
||||
// prod's lock) is folded into the same transaction as `deleted = true` so it's atomic with the
|
||||
// archive — a later failure can't strand a half-archived dev that's still flagged/locked.
|
||||
dev_lock_parent: Option<&str>,
|
||||
) -> Result<(usize, usize, usize)> {
|
||||
// Step 1: Disable all schedules and clear their queued jobs
|
||||
let mut tx = db.begin().await?;
|
||||
@@ -5246,6 +5609,30 @@ pub(crate) async fn archive_workspace_impl(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(prod) = dev_lock_parent {
|
||||
// Dissolve the dev pairing atomically with the archive: clear the canonical-dev flag (so the
|
||||
// archived row no longer occupies the parent's one-dev slot), and drop the prod's lock unless a
|
||||
// replacement dev already holds it (NOT EXISTS sees the just-cleared flag within this tx, so the
|
||||
// row being archived doesn't count).
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET is_dev_workspace = false WHERE id = $1",
|
||||
w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM workspace
|
||||
WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false
|
||||
)",
|
||||
prod,
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs)
|
||||
@@ -5289,8 +5676,39 @@ async fn archive_workspace(
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// If this is an attached dev workspace, archiving it leaves the prod with no active dev (the
|
||||
// unique index and user_workspaces both ignore deleted=true), so clear the prod's
|
||||
// dev_workspace_lock too. Gate it on prod-admin since it removes prod's protection rule (mirrors
|
||||
// detach/delete) — a dev-admin who isn't a prod-admin must not be able to unlock prod this way.
|
||||
let dev_lock_parent: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
if let Some(ref prod) = dev_lock_parent {
|
||||
let is_prod_admin = sqlx::query_scalar!(
|
||||
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
prod,
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_prod_admin
|
||||
&& !windmill_common::auth::is_super_admin_email(&db, &authed.email).await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"Archiving dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's
|
||||
// transaction, atomically with `deleted = true`.
|
||||
let (schedules_count, canceled_count, deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &w_id, &authed.username).await?;
|
||||
archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?;
|
||||
|
||||
// Audit log
|
||||
let mut tx = db.begin().await?;
|
||||
@@ -5325,6 +5743,10 @@ async fn archive_workspace(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(prod) = dev_lock_parent {
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens",
|
||||
&w_id, schedules_count, canceled_count, deleted_tokens_count
|
||||
@@ -6166,6 +6588,119 @@ async fn list_protection_rules(
|
||||
))
|
||||
}
|
||||
|
||||
/// Insert or replace a protection ruleset within an existing transaction. Unlike the
|
||||
/// `create_protection_rule` handler (which rejects an existing name), this upserts, so it is safe to
|
||||
/// call programmatically when designating a dev/prod pair. Callers MUST invalidate the
|
||||
/// protection-rules cache (`invalidate_protection_rules_cache`) after the transaction commits.
|
||||
async fn upsert_protection_rule(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
rules: ProtectionRules,
|
||||
bypass_groups: &[String],
|
||||
bypass_users: &[String],
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (workspace_id, name)
|
||||
DO UPDATE SET rules = EXCLUDED.rules,
|
||||
bypass_groups = EXCLUDED.bypass_groups,
|
||||
bypass_users = EXCLUDED.bypass_users
|
||||
"#,
|
||||
w_id,
|
||||
name,
|
||||
rules.bits(),
|
||||
bypass_groups,
|
||||
bypass_users,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock a prod workspace by applying the reserved dev-workspace lock rule with the selected
|
||||
/// restrictions (block direct deployment and/or ad-hoc forking). Non-admins are then funneled
|
||||
/// through the one dev workspace; admins bypass the rules (their existing escape hatch).
|
||||
async fn lock_prod_workspace(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
prod_w_id: &str,
|
||||
block_deploy: bool,
|
||||
block_forking: bool,
|
||||
) -> Result<()> {
|
||||
let mut rules = Vec::new();
|
||||
if block_deploy {
|
||||
rules.push(ProtectionRuleKind::DisableDirectDeployment);
|
||||
}
|
||||
if block_forking {
|
||||
rules.push(ProtectionRuleKind::DisableWorkspaceForking);
|
||||
}
|
||||
if rules.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
upsert_protection_rule(
|
||||
tx,
|
||||
prod_w_id,
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
ProtectionRules::from(&rules),
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the
|
||||
/// partial unique index `workspace_canonical_dev_idx` with a friendly message.
|
||||
async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result<()> {
|
||||
let existing = sqlx::query_scalar!(
|
||||
"SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||||
parent_w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
if let Some(existing) = existing {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace '{}' already has a dev workspace ('{}'). Detach it before creating another.",
|
||||
parent_w_id, existing
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A dev workspace pairs with a root prod workspace; nesting dev workspaces (a dev of a dev) isn't
|
||||
/// supported and would muddle the prod<->dev relationship.
|
||||
async fn ensure_dev_parent_is_root(db: &DB, parent_w_id: &str) -> Result<()> {
|
||||
let parent_is_fork = sqlx::query_scalar!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#,
|
||||
parent_w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if parent_is_fork {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Cannot create a dev workspace of '{}' because it is itself a fork or dev workspace.",
|
||||
parent_w_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `dev_workspace_lock` is owned by the dev-workspace feature (attach/detach/archive/delete create and
|
||||
/// remove it by name). Reserve it from the public protection-rule API so a user-managed rule can't
|
||||
/// collide: otherwise the feature's name-based cleanup would clobber the user's rule, or a manual edit
|
||||
/// could weaken the feature's lock.
|
||||
fn reject_reserved_rule_name(name: &str) -> Result<()> {
|
||||
if name == DEV_WORKSPACE_LOCK_RULE_NAME {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{}' is a reserved protection-rule name managed by the dev workspace feature",
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new protection rule
|
||||
async fn create_protection_rule(
|
||||
authed: ApiAuthed,
|
||||
@@ -6174,6 +6709,7 @@ async fn create_protection_rule(
|
||||
Json(req): Json<CreateProtectionRuleRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
reject_reserved_rule_name(&req.name)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -6248,6 +6784,7 @@ async fn update_protection_rule(
|
||||
Json(req): Json<UpdateProtectionRuleRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
reject_reserved_rule_name(&rule_name)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -6322,6 +6859,7 @@ async fn delete_protection_rule(
|
||||
Path((w_id, rule_name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
reject_reserved_rule_name(&rule_name)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -6466,10 +7004,22 @@ async fn compare_workspaces(
|
||||
}));
|
||||
}
|
||||
|
||||
// Honor ws_specific at read time: a workspace-specific resource/variable keeps its own value per
|
||||
// environment, so it must never appear in the normal diff (the per-item compare suppresses it,
|
||||
// but a cached `has_changes=true` row is trusted without re-running that compare, so filter those
|
||||
// here too). Seeding the initial copy onto a side that lacks it is a separate explicit action
|
||||
// (the "Create in <other>" button on the Workspace-specific list), not part of the diff. The row
|
||||
// is left intact, so unpinning resurfaces it without a re-tally.
|
||||
let diff_items = sqlx::query_as!(
|
||||
WorkspaceDiffRow,
|
||||
"SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff
|
||||
WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
|
||||
WHERE source_workspace_id = $1 AND fork_workspace_id = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ws_specific ws
|
||||
WHERE ws.path = workspace_diff.path
|
||||
AND ws.item_kind = workspace_diff.kind
|
||||
AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id)
|
||||
)",
|
||||
source_workspace_id,
|
||||
fork_workspace_id,
|
||||
)
|
||||
@@ -7197,7 +7747,6 @@ async fn compare_two_resources(
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
// If either side is ws_specific, consider unchanged
|
||||
let source_ws_specific = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)",
|
||||
source_workspace_id,
|
||||
@@ -7216,6 +7765,9 @@ async fn compare_two_resources(
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
// A workspace-specific resource keeps its own value per environment, so it never appears in the
|
||||
// diff (in either direction). Seeding the initial copy onto a side that lacks it is a separate
|
||||
// explicit action ("Create in <other>"), not a diff entry.
|
||||
if source_ws_specific || target_ws_specific {
|
||||
return Ok(ItemComparison {
|
||||
has_changes: false,
|
||||
@@ -7272,7 +7824,8 @@ async fn compare_two_variables(
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
// If either side is ws_specific, consider unchanged
|
||||
// A workspace-specific variable keeps its own value per environment, so it never appears in the
|
||||
// diff. Seeding the initial copy onto a side that lacks it is a separate explicit action.
|
||||
if presence.src_ws || presence.tgt_ws {
|
||||
return Ok(ItemComparison {
|
||||
has_changes: false,
|
||||
@@ -7786,6 +8339,19 @@ async fn list_ws_specific(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
// RLS gates membership/folder access, but a scoped API token must also be held to its read
|
||||
// scopes — mirror the resource/variable list endpoints, which filter with these predicates so a
|
||||
// token lacking `resources:read:*` / `variables:read:*` can't enumerate pinned paths it can't read.
|
||||
let resource_allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
let variable_allowed = build_scope_path_predicate(&authed, "variables", "read");
|
||||
let items = items
|
||||
.into_iter()
|
||||
.filter(|it| match it.item_kind.as_str() {
|
||||
"resource" => resource_allowed(&it.path),
|
||||
"variable" => variable_allowed(&it.path),
|
||||
_ => false,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
@@ -7808,6 +8374,17 @@ async fn list_ws_specific_versions(
|
||||
)));
|
||||
}
|
||||
|
||||
// A scoped API token must hold the read scope for this path, like the resource/variable read
|
||||
// endpoints. Without the scope, report no versions rather than leaking the path's history.
|
||||
let domain = if q.kind == "resource" {
|
||||
"resources"
|
||||
} else {
|
||||
"variables"
|
||||
};
|
||||
if !build_scope_path_predicate(&authed, domain, "read")(&q.path) {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
let versions: Vec<String> = sqlx::query_scalar!(
|
||||
r#"SELECT ws AS "ws!" FROM list_ws_specific_versions($1, $2, $3, $4)"#,
|
||||
&w_id,
|
||||
@@ -7820,3 +8397,155 @@ async fn list_ws_specific_versions(
|
||||
|
||||
Ok(Json(versions))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetWsSpecificBody {
|
||||
item_kind: String,
|
||||
path: String,
|
||||
value: bool,
|
||||
}
|
||||
|
||||
/// Mark (or unmark) a single resource/variable as workspace-specific. Pinning
|
||||
/// excludes it from the deploy diff so each environment keeps its own value
|
||||
/// (see `compare_two_resources`/`compare_two_variables`). Set per-workspace, so
|
||||
/// the compare page calls this once per side to flag both environments.
|
||||
async fn set_ws_specific(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(body): Json<SetWsSpecificBody>,
|
||||
) -> Result<String> {
|
||||
if body.item_kind != "resource" && body.item_kind != "variable" {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid kind '{}'. Must be 'resource' or 'variable'",
|
||||
body.item_kind
|
||||
)));
|
||||
}
|
||||
|
||||
// Reject a malformed path with a 400 before the auth check, which indexes the leading segments and
|
||||
// would otherwise panic (500) on a path like `u` with no segment. Accept all three shared path
|
||||
// shapes Windmill uses — `u/<user>`, `f/<folder>`, `g/<group>` (e.g. seeded `g/all/...` resources).
|
||||
let segs: Vec<&str> = body.path.split('/').collect();
|
||||
if segs.len() < 2 || !matches!(segs[0], "u" | "f" | "g") || segs[1].is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid {} path: {}",
|
||||
body.item_kind, body.path
|
||||
)));
|
||||
}
|
||||
|
||||
// Authorize like the resource/variable editors' own ws_specific toggle:
|
||||
// actual write access to the item + token scope + the workspace deploy rules.
|
||||
// `require_owner_of_path` is the real write gate (the resource editor uses it);
|
||||
// `check_scopes` only constrains scoped tokens (it is a no-op for session/cookie
|
||||
// logins). Together: a non-admin who can edit the item may pin it, while a
|
||||
// read-only member is rejected and a locked workspace still blocks non-deployers.
|
||||
// `require_is_writer` matches the resource/variable editors' write semantics (owner, folder
|
||||
// writer, or item writer via extra_perms) — not owner-only.
|
||||
let writer_query = if body.item_kind == "resource" {
|
||||
"SELECT extra_perms FROM resource WHERE path = $1 AND workspace_id = $2"
|
||||
} else {
|
||||
"SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2"
|
||||
};
|
||||
require_is_writer(
|
||||
&authed,
|
||||
&body.path,
|
||||
&w_id,
|
||||
db.clone(),
|
||||
writer_query,
|
||||
&body.item_kind,
|
||||
)
|
||||
.await?;
|
||||
check_scopes(&authed, || {
|
||||
format!("{}s:write:{}", body.item_kind, body.path)
|
||||
})?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
if body.value {
|
||||
// Existence guard keeps a dangling marker from being created for a
|
||||
// path absent in this workspace.
|
||||
if body.item_kind == "resource" {
|
||||
sqlx::query!(
|
||||
"INSERT INTO ws_specific (workspace_id, item_kind, path)
|
||||
SELECT $1::varchar, 'resource', $2::varchar
|
||||
WHERE EXISTS (SELECT 1 FROM resource WHERE workspace_id = $1::varchar AND path = $2::varchar)
|
||||
ON CONFLICT DO NOTHING",
|
||||
w_id,
|
||||
body.path,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A resource owns its `$var:` secrets, so pin those too.
|
||||
windmill_store::resources::mark_linked_variables_ws_specific(
|
||||
&mut tx, &authed, &w_id, &body.path,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"INSERT INTO ws_specific (workspace_id, item_kind, path)
|
||||
SELECT $1::varchar, 'variable', $2::varchar
|
||||
WHERE EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1::varchar AND path = $2::varchar)
|
||||
ON CONFLICT DO NOTHING",
|
||||
w_id,
|
||||
body.path,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
// Unmark only this item; linked variables stay flagged (they may be
|
||||
// referenced by other resources) — mirrors the resource-form toggle.
|
||||
sqlx::query!(
|
||||
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = $2 AND path = $3",
|
||||
w_id,
|
||||
body.item_kind,
|
||||
body.path,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// While pinned, the item's cached workspace_diff verdict was never recomputed (the compare
|
||||
// read filter excludes it), so it may now be stale in either direction. Mark it NULL so the
|
||||
// next compare re-evaluates from scratch — and the now-shared item reappears (or is dropped)
|
||||
// correctly instead of being stuck on its pre-pin verdict.
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_diff SET has_changes = NULL
|
||||
WHERE path = $2 AND kind = $3
|
||||
AND ($1 IN (source_workspace_id, fork_workspace_id))",
|
||||
w_id,
|
||||
body.path,
|
||||
body.item_kind,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let value_str = body.value.to_string();
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
&format!("{}s.set_ws_specific", body.item_kind),
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&body.path),
|
||||
Some([("value", value_str.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(format!(
|
||||
"Set workspace-specific={} for {} {}",
|
||||
body.value, body.item_kind, body.path
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use windmill_api_auth::{require_super_admin, ApiAuthed};
|
||||
use windmill_common::workspaces::WM_FORK_PREFIX;
|
||||
use windmill_common::DB;
|
||||
|
||||
use crate::workspaces::{
|
||||
@@ -26,7 +25,7 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
utils::require_admin,
|
||||
workspaces::DataTable,
|
||||
workspaces::{DataTable, DEV_WORKSPACE_LOCK_RULE_NAME, WM_FORK_PREFIX},
|
||||
};
|
||||
use windmill_queue::schedule::{get_schedule_opt, push_scheduled_job};
|
||||
|
||||
@@ -65,22 +64,42 @@ pub(crate) async fn change_workspace_id(
|
||||
old_id, rw.new_id
|
||||
);
|
||||
|
||||
// Create new workspace with new id and name. A fork that keeps a wm-fork-
|
||||
// id must carry its parent_workspace_id over, otherwise it becomes a
|
||||
// parentless "fork of nothing" with no source to compare or merge against.
|
||||
// A non-fork target id means the workspace is being promoted out of a fork,
|
||||
// so the parent pointer is intentionally cleared.
|
||||
// Create new workspace with new id and name. Fork lineage AND the dev designation are preserved
|
||||
// from the source row, not inferred from the new id's prefix: a prefix-less fork (a dev or
|
||||
// detached-dev workspace) would otherwise be silently promoted to a root workspace, and a dev
|
||||
// would lose its flag — leaving its prod locked with no canonical dev. Promoting out of a fork is
|
||||
// a separate, explicit action — a rename never does it implicitly.
|
||||
info!("Creating new workspace row");
|
||||
let new_is_fork = rw.new_id.starts_with(WM_FORK_PREFIX);
|
||||
let old = sqlx::query!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!", is_dev_workspace
|
||||
FROM workspace WHERE id = $1"#,
|
||||
&old_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let new_is_fork = old.as_ref().map(|o| o.has_parent).unwrap_or(false);
|
||||
let new_is_dev = new_is_fork && old.as_ref().map(|o| o.is_dev_workspace).unwrap_or(false);
|
||||
// Neutralize the old row's dev flag BEFORE inserting the new one: the move-and-archive archives
|
||||
// the old row only later, so without this the new dev row and the not-yet-archived old dev row
|
||||
// would momentarily both be active under the same parent and trip the one-dev-per-parent index.
|
||||
if new_is_dev {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET is_dev_workspace = false WHERE id = $1",
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)
|
||||
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace)
|
||||
SELECT $1, $2, owner, false, premium,
|
||||
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END
|
||||
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5
|
||||
FROM workspace WHERE id = $3",
|
||||
&rw.new_id,
|
||||
&rw.new_name,
|
||||
&old_id,
|
||||
new_is_fork
|
||||
new_is_fork,
|
||||
new_is_dev
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -360,8 +379,18 @@ pub(crate) async fn change_workspace_id(
|
||||
// must follow the renamed parent to the new id, otherwise it is left
|
||||
// pointing at the soft-deleted old shell (whose data has moved here).
|
||||
info!("Re-parenting child forks to the new workspace id");
|
||||
let reparented_children: Vec<String> = sqlx::query_scalar!(
|
||||
"UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2 RETURNING id",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A dev/fork's `deploy_to` points at the prod root, so it must follow the rename too — otherwise
|
||||
// the child re-parents to the new id but still deploys to the soft-deleted old shell.
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
|
||||
"UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
@@ -696,11 +725,18 @@ pub(crate) async fn change_workspace_id(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// The children's parent_workspace_id changed (old root -> new root); invalidate their fork-parent
|
||||
// routing cache so jobs route under the renamed root rather than the old (archived) one until the
|
||||
// 300s TTL would otherwise expire.
|
||||
for child in &reparented_children {
|
||||
windmill_queue::tags::invalidate_fork_parent_cache(child);
|
||||
}
|
||||
|
||||
// Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true
|
||||
// Note: schedules were already moved to new workspace, so this will find 0 schedules
|
||||
info!("Archiving old workspace");
|
||||
let (_schedules_count, canceled_count, _deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &old_id, &authed.username).await?;
|
||||
archive_workspace_impl(&db, &old_id, &authed.username, None).await?;
|
||||
|
||||
info!(
|
||||
"Workspace id change completed: moved {} to {}, archived old workspace",
|
||||
@@ -734,15 +770,65 @@ pub(crate) async fn delete_workspace(
|
||||
_ => Ok(w_id),
|
||||
}?;
|
||||
|
||||
if dwq.only_delete_forks.unwrap_or(false) && !w_id.starts_with(WM_FORK_PREFIX) {
|
||||
let is_fork = workspace_is_fork(&db, &w_id).await?;
|
||||
if dwq.only_delete_forks.unwrap_or(false) && !is_fork {
|
||||
return Err(Error::BadRequest(
|
||||
"Cannot delete this workspace because it is not a workspace fork.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
if !(w_id.starts_with(WM_FORK_PREFIX) && is_workspace_owner(&authed, &w_id, &mut tx).await?) {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
|
||||
&& !is_super_admin_email(&db, &authed.email).await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Deleting this workspace requires being the fork's owner or a superadmin".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Don't hard-delete a workspace that still has a dev workspace paired to it: the FK is
|
||||
// ON DELETE SET NULL, which would orphan the (prefix-less) dev into a parentless, non-fork row
|
||||
// its owner could no longer self-delete. Require detaching/deleting the dev first. Ordinary
|
||||
// forks have no such guard — they keep their prefix and stay owner-deletable when orphaned.
|
||||
// Archived devs (deleted = true) are included: they keep is_dev_workspace = true, so SET NULL on
|
||||
// their parent would violate the `is_dev ⇒ has parent` CHECK and fail the whole delete with a 500.
|
||||
if let Some(dev_id) = sqlx::query_scalar!(
|
||||
"SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Cannot delete workspace '{}' because it has a dev workspace ('{}'). Detach or delete the dev workspace first.",
|
||||
w_id, dev_id
|
||||
)));
|
||||
}
|
||||
|
||||
// Deleting an attached dev workspace removes the parent prod's dev_workspace_lock (below), so it
|
||||
// must be a prod-admin action, not just the dev's own owner (dev ownership can diverge from
|
||||
// prod's) — mirrors detach_dev_workspace, which is prod-admin gated.
|
||||
if let Some(prod) = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
{
|
||||
let is_prod_admin = sqlx::query_scalar!(
|
||||
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
&prod,
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_prod_admin && !is_super_admin_email(&db, &authed.email).await? {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"Deleting dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
|
||||
@@ -920,10 +1006,31 @@ pub(crate) async fn delete_workspace(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// If this workspace is itself a dev workspace, deleting it dissolves the pairing, so also drop
|
||||
// the parent prod's reserved dev_workspace_lock (mirrors detach_dev_workspace) — otherwise prod
|
||||
// stays locked against direct deploy/forking with no dev workspace left to make changes in.
|
||||
let dev_lock_parent: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(ref parent) = dev_lock_parent {
|
||||
sqlx::query!(
|
||||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||||
parent,
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Record under the instance-level "admins" workspace. The per-workspace audit
|
||||
// rows are deleted along with the workspace, so this instance-level entry is the
|
||||
// only durable, superadmin-discoverable record of who deleted the workspace.
|
||||
@@ -939,6 +1046,10 @@ pub(crate) async fn delete_workspace(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(parent) = dev_lock_parent {
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&parent);
|
||||
}
|
||||
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
}
|
||||
|
||||
@@ -957,9 +1068,15 @@ pub async fn drop_forked_datatable_databases(
|
||||
Json(req): Json<DropForkedDatatableDatabasesRequest>,
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
// Same permission check as delete_workspace: fork owner or super admin
|
||||
let is_fork = workspace_is_fork(&db, &w_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
if !(w_id.starts_with(WM_FORK_PREFIX) && is_workspace_owner(&authed, &w_id, &mut tx).await?) {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
|
||||
&& !is_super_admin_email(&db, &authed.email).await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Dropping forked datatable databases requires being the fork's owner or a superadmin"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -1103,3 +1220,21 @@ async fn is_workspace_owner(
|
||||
.await?;
|
||||
Ok(owner.map(|o| o == authed.email).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Whether a workspace is a fork or dev workspace. Both forks and dev workspaces set
|
||||
/// `parent_workspace_id`, but a `wm-fork-` workspace can outlive its parent (the FK is
|
||||
/// `ON DELETE SET NULL`), so also treat the prefix as fork-ness — otherwise an orphaned fork would
|
||||
/// lose owner-self-delete. Used to gate owner-self-delete, which is permitted for forks/dev
|
||||
/// workspaces but requires superadmin otherwise.
|
||||
async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
|
||||
if w_id.starts_with(WM_FORK_PREFIX) {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(sqlx::query_scalar!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
@@ -1184,6 +1184,89 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/attach_dev_workspace:
|
||||
post:
|
||||
summary: attach an existing workspace as the dev workspace of this (prod) workspace
|
||||
operationId: attachDevWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
dev_workspace_id:
|
||||
type: string
|
||||
lock_prod_deploy:
|
||||
type: boolean
|
||||
lock_prod_forking:
|
||||
type: boolean
|
||||
required:
|
||||
- dev_workspace_id
|
||||
responses:
|
||||
"200":
|
||||
description: dev workspace attached
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/detach_dev_workspace:
|
||||
post:
|
||||
summary: detach the dev workspace from this (prod) workspace
|
||||
operationId: detachDevWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
dev_workspace_id:
|
||||
type: string
|
||||
required:
|
||||
- dev_workspace_id
|
||||
responses:
|
||||
"200":
|
||||
description: dev workspace detached
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/get_dev_workspace:
|
||||
get:
|
||||
summary: this workspace's active canonical dev workspace, if any
|
||||
operationId: getDevWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: the dev workspace, or null if none
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
nullable: true
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
|
||||
/workspaces/exists:
|
||||
post:
|
||||
summary: exists workspace
|
||||
@@ -6238,6 +6321,42 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/set_ws_specific:
|
||||
post:
|
||||
summary: mark or unmark an item as workspace-specific
|
||||
operationId: setWsSpecific
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
item_kind:
|
||||
type: string
|
||||
enum:
|
||||
- resource
|
||||
- variable
|
||||
path:
|
||||
type: string
|
||||
value:
|
||||
type: boolean
|
||||
required:
|
||||
- item_kind
|
||||
- path
|
||||
- value
|
||||
responses:
|
||||
"200":
|
||||
description: workspace-specific flag updated
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/public_app_rate_limit:
|
||||
post:
|
||||
summary: Set public app rate limit for this workspace
|
||||
@@ -27756,6 +27875,8 @@ components:
|
||||
parent_workspace_id:
|
||||
type: string
|
||||
nullable: true
|
||||
is_dev_workspace:
|
||||
type: boolean
|
||||
created_by:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -27766,6 +27887,7 @@ components:
|
||||
- name
|
||||
- username
|
||||
- color
|
||||
- is_dev_workspace
|
||||
- disabled
|
||||
required:
|
||||
- email
|
||||
@@ -27807,6 +27929,18 @@ components:
|
||||
new_dbname:
|
||||
type: string
|
||||
description: "New database name for the fork"
|
||||
is_dev_workspace:
|
||||
type: boolean
|
||||
description: "Create the fork as a persistent dev workspace (id not required to carry the wm-fork- prefix; at most one per parent)"
|
||||
lock_prod_deploy:
|
||||
type: boolean
|
||||
description: "When creating a dev workspace, lock the parent (prod) against direct deployment"
|
||||
lock_prod_forking:
|
||||
type: boolean
|
||||
description: "When creating a dev workspace, prevent forking the parent (prod)"
|
||||
copy_members:
|
||||
type: boolean
|
||||
description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
|
||||
@@ -179,7 +179,24 @@ pub const WM_FORK_PREFIX: &str = "wm-fork-";
|
||||
/// layer because the actual branch creation runs in a deferred git-sync worker job — without
|
||||
/// this check, the API returns 200 and the failure only surfaces later in the worker.
|
||||
pub fn validate_fork_workspace_id(id: &str) -> error::Result<()> {
|
||||
if !id.starts_with(WM_FORK_PREFIX) {
|
||||
validate_workspace_branch_id(id, true)
|
||||
}
|
||||
|
||||
/// Like [`validate_fork_workspace_id`] but does not require the `wm-fork-` prefix. Used for dev
|
||||
/// workspaces, whose id is an ordinary (prefix-less) workspace id but must still be git-branch-safe
|
||||
/// because it is interpolated into a `wm-fork/<original_branch>/<id>` branch name like any fork.
|
||||
pub fn validate_dev_workspace_id(id: &str) -> error::Result<()> {
|
||||
validate_workspace_branch_id(id, false)
|
||||
}
|
||||
|
||||
fn validate_workspace_branch_id(id: &str, require_fork_prefix: bool) -> error::Result<()> {
|
||||
if id.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"Workspace id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if require_fork_prefix && !id.starts_with(WM_FORK_PREFIX) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"The id `{}` is invalid for a forked workspace. It should be prefixed by {}",
|
||||
id, WM_FORK_PREFIX
|
||||
@@ -188,8 +205,9 @@ pub fn validate_fork_workspace_id(id: &str) -> error::Result<()> {
|
||||
|
||||
if id.len() > 50 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Fork workspace id `{}` is too long ({} chars). Maximum length is 50 characters (including the '{}' prefix).",
|
||||
id, id.len(), WM_FORK_PREFIX
|
||||
"Workspace id `{}` is too long ({} chars). Maximum length is 50 characters.",
|
||||
id,
|
||||
id.len()
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -438,6 +456,11 @@ pub enum RuleCheckResult {
|
||||
/// - User is not in bypass lists
|
||||
///
|
||||
/// Returns `Err` if the rule is not found
|
||||
/// Reserved protection-rule name applied to a prod workspace paired with a dev workspace. It carries
|
||||
/// `DisableDirectDeployment` + `DisableWorkspaceForking` and is auto-managed by the dev-workspace
|
||||
/// feature (applied on pairing, removed on detach).
|
||||
pub const DEV_WORKSPACE_LOCK_RULE_NAME: &str = "dev_workspace_lock";
|
||||
|
||||
pub async fn check_user_against_rule(
|
||||
workspace_id: &str,
|
||||
rule: &ProtectionRuleKind,
|
||||
@@ -795,6 +818,30 @@ mod tests {
|
||||
assert!(validate_fork_workspace_id("wm-fork-foo/bar.lock").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_dev_workspace_id_accepts_prefixless_valid() {
|
||||
// Dev workspaces use ordinary, prefix-less ids but must stay git-branch-safe.
|
||||
validate_dev_workspace_id("dev").unwrap();
|
||||
validate_dev_workspace_id("my-dev-workspace").unwrap();
|
||||
validate_dev_workspace_id("staging.42").unwrap();
|
||||
// The fork prefix is allowed but not required.
|
||||
validate_dev_workspace_id("wm-fork-dev").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_dev_workspace_id_rejects_empty_and_git_unsafe() {
|
||||
assert!(validate_dev_workspace_id("").is_err());
|
||||
assert!(validate_dev_workspace_id("dev workspace").is_err());
|
||||
assert!(validate_dev_workspace_id("dev..staging").is_err());
|
||||
assert!(validate_dev_workspace_id("dev/.x").is_err());
|
||||
assert!(validate_dev_workspace_id("dev.lock").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fork_workspace_id_rejects_empty() {
|
||||
assert!(validate_fork_workspace_id("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fork_workspace_id_rejects_too_long() {
|
||||
let long_id = format!("wm-fork-{}", "a".repeat(43));
|
||||
|
||||
@@ -8,13 +8,21 @@ const FORK_PARENT_CACHE_TTL_SECS: u64 = 300;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// Cache of fork workspace id -> (parent_workspace_id, cached_at).
|
||||
// `parent_workspace_id` is essentially immutable once a fork is created, so a multi-minute TTL
|
||||
// is safe. `None` means the lookup found no parent (or the DB call failed); we still cache it
|
||||
// briefly so that forks missing a parent do not hammer the DB.
|
||||
// `parent_workspace_id` is stable for the lifetime of a fork EXCEPT across attach/detach of a
|
||||
// dev workspace, which set/keep it; those paths call `invalidate_fork_parent_cache` so routing
|
||||
// doesn't lag. `None` means the lookup found no parent (or the DB call failed); we still cache
|
||||
// it briefly so that forks missing a parent do not hammer the DB.
|
||||
static ref FORK_PARENT_CACHE: quick_cache::sync::Cache<String, (Option<String>, std::time::Instant)> =
|
||||
quick_cache::sync::Cache::new(500);
|
||||
}
|
||||
|
||||
/// Drop the cached fork->parent mapping for a workspace. Call after mutating `parent_workspace_id`
|
||||
/// (attaching/detaching a dev workspace) so per-workspace job tags resolve to the new parent
|
||||
/// immediately instead of after the cache TTL.
|
||||
pub fn invalidate_fork_parent_cache(workspace_id: &str) {
|
||||
FORK_PARENT_CACHE.remove(workspace_id);
|
||||
}
|
||||
|
||||
/// Returns `Some(effective_workspace_tag_id)` if jobs of `workspace_id` should use workspace-
|
||||
/// specific tags, where `effective_workspace_tag_id` is the string embedded in the tag. For forks,
|
||||
/// this is always the parent workspace id, optionally suffixed with `-fork` (controlled by the
|
||||
@@ -26,16 +34,14 @@ pub async fn per_workspace_tag(workspace_id: &str, db: &Pool<Postgres>) -> Optio
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_fork = workspace_id.starts_with(WM_FORK_PREFIX);
|
||||
|
||||
// For forks, always resolve to the parent workspace id; regular workspaces avoid the lookup.
|
||||
let effective_ws_id: String = if is_fork {
|
||||
lookup_fork_parent(workspace_id, db)
|
||||
.await
|
||||
.unwrap_or_else(|| workspace_id.to_string()) // no parent found -> fall back to fork's own id
|
||||
} else {
|
||||
workspace_id.to_string()
|
||||
};
|
||||
// Resolve to the parent workspace id when the workspace is a fork or dev workspace (both set
|
||||
// parent_workspace_id). The lookup caches its `None` result, so non-forks stay cheap after warmup
|
||||
// (and the common case is already short-circuited by the global toggle above).
|
||||
let parent = lookup_fork_parent(workspace_id, db).await;
|
||||
// A `wm-fork-` workspace can outlive its parent (the FK is `ON DELETE SET NULL`), so keep
|
||||
// treating the prefix as fork-ness for the `-fork` suffix even when the parent link is gone.
|
||||
let is_fork = parent.is_some() || workspace_id.starts_with(WM_FORK_PREFIX);
|
||||
let effective_ws_id: String = parent.unwrap_or_else(|| workspace_id.to_string());
|
||||
|
||||
// Whitelist check is against the resolved (parent) id so that including a parent in the
|
||||
// whitelist transparently covers all of its forks.
|
||||
@@ -62,8 +68,10 @@ pub async fn per_workspace_tag(workspace_id: &str, db: &Pool<Postgres>) -> Optio
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the parent workspace id for a fork, or `None` if the fork has no parent set (or the
|
||||
/// DB lookup failed). Backed by a short-TTL cache to avoid a DB round-trip per job push.
|
||||
/// Returns the parent workspace id for a fork, or `None` if the fork has no parent set. Backed by a
|
||||
/// short-TTL cache to avoid a DB round-trip per job push. A transient DB error returns `None` for
|
||||
/// this call but is NOT cached, so the next push retries instead of misrouting a (prefix-less) dev
|
||||
/// workspace's jobs for the whole TTL.
|
||||
async fn lookup_fork_parent(fork_id: &str, db: &Pool<Postgres>) -> Option<String> {
|
||||
if let Some((parent, cached_at)) = FORK_PARENT_CACHE.get(fork_id) {
|
||||
if cached_at.elapsed().as_secs() < FORK_PARENT_CACHE_TTL_SECS {
|
||||
@@ -78,8 +86,11 @@ async fn lookup_fork_parent(fork_id: &str, db: &Pool<Postgres>) -> Option<String
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(Some(parent))) => Some(parent),
|
||||
_ => None,
|
||||
Ok(opt) => opt.flatten(),
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to look up fork parent for {fork_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
FORK_PARENT_CACHE.insert(
|
||||
|
||||
@@ -1088,21 +1088,48 @@ async fn create_resource(
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource
|
||||
(workspace_id, path, value, description, resource_type, created_by, edited_at, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)
|
||||
DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
|
||||
w_id,
|
||||
resource.path,
|
||||
raw_json as sqlx::types::Json<&RawValue>,
|
||||
resource.description,
|
||||
resource.resource_type,
|
||||
authed.username,
|
||||
resource.labels.as_deref() as Option<&[String]>
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if update_if_exists {
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource
|
||||
(workspace_id, path, value, description, resource_type, created_by, edited_at, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)
|
||||
DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
|
||||
w_id,
|
||||
resource.path,
|
||||
raw_json as sqlx::types::Json<&RawValue>,
|
||||
resource.description,
|
||||
resource.resource_type,
|
||||
authed.username,
|
||||
resource.labels.as_deref() as Option<&[String]>
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
// Create-only (the default): DO NOTHING + a row-count guard, so a path that appears between
|
||||
// check_path_conflict above and this insert is rejected rather than overwritten. A plain
|
||||
// DO UPDATE here would clobber a concurrently-created resource, breaking create-only callers
|
||||
// (e.g. Compare & Deploy "Create in <other>").
|
||||
let inserted = sqlx::query!(
|
||||
"INSERT INTO resource
|
||||
(workspace_id, path, value, description, resource_type, created_by, edited_at, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) DO NOTHING",
|
||||
w_id,
|
||||
resource.path,
|
||||
raw_json as sqlx::types::Json<&RawValue>,
|
||||
resource.description,
|
||||
resource.resource_type,
|
||||
authed.username,
|
||||
resource.labels.as_deref() as Option<&[String]>
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if inserted.rows_affected() == 0 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Resource {} already exists",
|
||||
resource.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror update_resource: Some(true) inserts, Some(false) clears (only
|
||||
// meaningful on the upsert path, since a pure create has no existing row),
|
||||
@@ -1385,7 +1412,12 @@ fn collect_var_refs(value: &serde_json::Value, out: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_linked_variables_ws_specific(
|
||||
/// Marks every variable referenced by the resource at `resource_path` as workspace-specific.
|
||||
///
|
||||
/// AUTH CONTRACT: this mutates `ws_specific` and does NOT check authorization itself. The caller
|
||||
/// MUST verify that `authed` has write access to the resource at `resource_path` in `w_id` (e.g. via
|
||||
/// `require_owner_of_path`) before calling it.
|
||||
pub async fn mark_linked_variables_ws_specific(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
|
||||
Generated
+2
-48
@@ -878,7 +878,6 @@
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -890,7 +889,6 @@
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -901,7 +899,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1417,7 +1414,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1566,7 +1562,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1583,7 +1578,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1600,7 +1594,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1617,7 +1610,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1634,7 +1626,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1651,7 +1642,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1668,7 +1658,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1685,7 +1674,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1702,7 +1690,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1719,7 +1706,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1736,7 +1722,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1753,7 +1738,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1770,7 +1754,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1789,7 +1772,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1806,7 +1788,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2112,7 +2093,6 @@
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -7348,7 +7328,7 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -7883,7 +7863,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7904,7 +7883,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7925,7 +7903,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7946,7 +7923,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7967,7 +7943,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7988,7 +7963,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8009,7 +7983,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8030,7 +8003,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8051,7 +8023,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8072,7 +8043,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8093,7 +8063,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12781,21 +12750,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-eslint-parser": {
|
||||
"version": "0.43.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
|
||||
@@ -13535,7 +13489,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
AppService,
|
||||
FlowService,
|
||||
FolderService,
|
||||
ResourceService,
|
||||
ScriptService,
|
||||
UserService,
|
||||
VariableService,
|
||||
WorkspaceService,
|
||||
type WorkspaceComparison,
|
||||
type WorkspaceItemDiff
|
||||
@@ -97,6 +99,243 @@
|
||||
onChanged
|
||||
}: Props = $props()
|
||||
|
||||
// Workspace-specific ("pinned") resources/variables. They keep their own
|
||||
// value per environment and are suppressed from the diff, so the page fetches
|
||||
// them separately to keep them visible and un-pinnable.
|
||||
// `onCurrent`/`onParent` track which side carries the ws_specific marker — which, because marks
|
||||
// are existence-guarded, also tells us which side the item exists on. An item present on only one
|
||||
// side can be seeded onto the other via "Create in <other>".
|
||||
type PinnedItem = { item_kind: string; path: string; onCurrent: boolean; onParent: boolean }
|
||||
let pinnedItems = $state<PinnedItem[]>([])
|
||||
let pinBusy = $state<Record<string, boolean>>({})
|
||||
let createConfirm = $state<PinnedItem | undefined>(undefined)
|
||||
|
||||
// Whether this item is already workspace-specific (marked on either side); used to avoid offering
|
||||
// to pin it again from a diff row.
|
||||
const isPinned = (kind: string, path: string) =>
|
||||
pinnedItems.some((it) => it.item_kind === kind && it.path === path)
|
||||
|
||||
// Bumped on each load so a response that resolves after the compared pair
|
||||
// changed can't overwrite the newer pair's items.
|
||||
let pinnedReqSeq = 0
|
||||
async function loadPinned() {
|
||||
const seq = ++pinnedReqSeq
|
||||
try {
|
||||
const [cur, par] = await Promise.all([
|
||||
WorkspaceService.listWsSpecific({ workspace: currentWorkspaceId }),
|
||||
WorkspaceService.listWsSpecific({ workspace: parentWorkspaceId })
|
||||
])
|
||||
if (seq !== pinnedReqSeq) return
|
||||
const map = new Map<string, PinnedItem>()
|
||||
const mark = (it: { item_kind: string; path: string }, side: 'cur' | 'par') => {
|
||||
const k = `${it.item_kind}:${it.path}`
|
||||
const e = map.get(k) ?? {
|
||||
item_kind: it.item_kind,
|
||||
path: it.path,
|
||||
onCurrent: false,
|
||||
onParent: false
|
||||
}
|
||||
if (side === 'cur') e.onCurrent = true
|
||||
else e.onParent = true
|
||||
map.set(k, e)
|
||||
}
|
||||
for (const it of cur) mark(it, 'cur')
|
||||
for (const it of par) mark(it, 'par')
|
||||
pinnedItems = [...map.values()].sort((a, b) =>
|
||||
`${a.item_kind}:${a.path}`.localeCompare(`${b.item_kind}:${b.path}`)
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to load workspace-specific items', e)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-fetch whenever the compared pair changes.
|
||||
currentWorkspaceId
|
||||
parentWorkspaceId
|
||||
loadPinned()
|
||||
})
|
||||
|
||||
// Flag both environments so each side marks the item workspace-specific, not
|
||||
// only the workspace the compare is viewed from. `value=false` makes it
|
||||
// shared again.
|
||||
async function setPinned(kind: 'resource' | 'variable', path: string, value: boolean) {
|
||||
const k = `${kind}:${path}`
|
||||
pinBusy[k] = true
|
||||
try {
|
||||
const targets = [currentWorkspaceId, parentWorkspaceId]
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((ws) =>
|
||||
WorkspaceService.setWsSpecific({
|
||||
workspace: ws,
|
||||
requestBody: { item_kind: kind, path, value }
|
||||
})
|
||||
)
|
||||
)
|
||||
const verb = value ? 'workspace specific' : 'shared'
|
||||
const failed = results.filter((r) => r.status === 'rejected').length
|
||||
if (failed === targets.length) {
|
||||
sendUserToast(`Failed to update workspace-specific for ${path}`, true)
|
||||
} else if (failed > 0) {
|
||||
sendUserToast(`Made ${path} ${verb} in one environment only (no access to the other)`)
|
||||
} else {
|
||||
sendUserToast(`Made ${path} ${verb}`)
|
||||
}
|
||||
await loadPinned()
|
||||
onChanged?.()
|
||||
} finally {
|
||||
pinBusy[k] = false
|
||||
}
|
||||
}
|
||||
|
||||
// Collect `$var:` variable references from a resource value (mirrors the backend `collect_var_refs`).
|
||||
function collectVarRefs(value: unknown, out: string[]) {
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('$var:')) out.push(value.slice('$var:'.length))
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) collectVarRefs(v, out)
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const v of Object.values(value)) collectVarRefs(v, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the item on the target from the source value, strictly create-only: the backend create
|
||||
// endpoints reject an existing path (`check_path_conflict`), so unlike `deployItem` — which updates
|
||||
// on conflict — this never overwrites a target created concurrently. Returns 'created', 'conflict'
|
||||
// (already present on the target), or throws for a real error.
|
||||
async function createItemOnly(
|
||||
kind: 'resource' | 'variable',
|
||||
path: string,
|
||||
from: string,
|
||||
to: string
|
||||
): Promise<'created' | 'conflict'> {
|
||||
try {
|
||||
if (kind === 'resource') {
|
||||
const r = await ResourceService.getResource({ workspace: from, path })
|
||||
await ResourceService.createResource({
|
||||
workspace: to,
|
||||
requestBody: {
|
||||
path,
|
||||
value: r.value ?? '',
|
||||
description: r.description ?? '',
|
||||
resource_type: r.resource_type
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const v = await VariableService.getVariable({ workspace: from, path, decryptSecret: true })
|
||||
await VariableService.createVariable({
|
||||
workspace: to,
|
||||
requestBody: {
|
||||
path,
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret ?? false,
|
||||
description: v.description ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
return 'created'
|
||||
} catch (e) {
|
||||
// Don't parse the error message: re-check existence. If the target now exists, the create
|
||||
// lost a race (the backend create is create-only and rejected it), so report a conflict and
|
||||
// leave it untouched rather than overwriting.
|
||||
const existsNow =
|
||||
kind === 'resource'
|
||||
? await ResourceService.existsResource({ workspace: to, path })
|
||||
: await VariableService.existsVariable({ workspace: to, path })
|
||||
if (existsNow) return 'conflict'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Seed a resource's linked `$var:` variables into the target environment, copying each one's value
|
||||
// (incl. secrets) only where the target lacks it (create-only). Mirrors the backend's
|
||||
// `mark_linked_variables_ws_specific` cascade so a seeded resource resolves its references and its
|
||||
// secrets stay per-environment. Returns the paths that failed to seed.
|
||||
async function seedMissingLinkedVars(
|
||||
resourcePath: string,
|
||||
from: string,
|
||||
to: string
|
||||
): Promise<string[]> {
|
||||
const resource = await ResourceService.getResource({ workspace: from, path: resourcePath })
|
||||
const refs: string[] = []
|
||||
collectVarRefs(resource.value, refs)
|
||||
const failed: string[] = []
|
||||
for (const varPath of Array.from(new Set(refs))) {
|
||||
if (await VariableService.existsVariable({ workspace: to, path: varPath })) continue
|
||||
try {
|
||||
// 'created' and 'conflict' (now present, created concurrently) are both the desired end
|
||||
// state; only a real error counts as a failure to seed.
|
||||
await createItemOnly('variable', varPath, from, to)
|
||||
} catch (e) {
|
||||
failed.push(varPath)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// Seed a workspace-specific item onto a side that lacks it, copying its current value (incl.
|
||||
// secret values). Strictly create-only: markers can be one-sided (a pin can fail on a locked
|
||||
// side), so a missing marker doesn't prove the item is missing — re-check actual existence and,
|
||||
// if the target already has it, mark it workspace-specific there instead of overwriting its value.
|
||||
async function createOnRemote(it: PinnedItem) {
|
||||
const from = it.onCurrent ? currentWorkspaceId : parentWorkspaceId
|
||||
const to = it.onCurrent ? parentWorkspaceId : currentWorkspaceId
|
||||
const k = `${it.item_kind}:${it.path}`
|
||||
const kindCast = it.item_kind as 'resource' | 'variable'
|
||||
pinBusy[k] = true
|
||||
try {
|
||||
const existsOnTarget =
|
||||
kindCast === 'resource'
|
||||
? await ResourceService.existsResource({ workspace: to, path: it.path })
|
||||
: await VariableService.existsVariable({ workspace: to, path: it.path })
|
||||
if (existsOnTarget) {
|
||||
// Don't overwrite — just mark the existing target item workspace-specific.
|
||||
await WorkspaceService.setWsSpecific({
|
||||
workspace: to,
|
||||
requestBody: { item_kind: kindCast, path: it.path, value: true }
|
||||
})
|
||||
sendUserToast(
|
||||
`${it.path} already exists in ${to} — marked it workspace-specific instead of overwriting`
|
||||
)
|
||||
await loadPinned()
|
||||
onChanged?.()
|
||||
return
|
||||
}
|
||||
// A resource owns its `$var:` secrets, so seed any linked variables the target lacks before
|
||||
// creating the resource — otherwise it would reference variables that don't exist there. If
|
||||
// any linked variable fails to seed, abort rather than create a resource with dangling refs.
|
||||
const linkedFailed =
|
||||
kindCast === 'resource' ? await seedMissingLinkedVars(it.path, from, to) : []
|
||||
if (linkedFailed.length > 0) {
|
||||
sendUserToast(
|
||||
`Did not create ${it.path} in ${to}: failed to seed linked variable(s) ${linkedFailed.join(', ')}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Create-only: a 'conflict' means the target appeared between the existence check above and
|
||||
// the create, so it was left untouched rather than overwritten.
|
||||
const result = await createItemOnly(kindCast, it.path, from, to)
|
||||
// Marking cascades to mark a resource's now-present linked variables workspace-specific.
|
||||
await WorkspaceService.setWsSpecific({
|
||||
workspace: to,
|
||||
requestBody: { item_kind: kindCast, path: it.path, value: true }
|
||||
})
|
||||
sendUserToast(
|
||||
result === 'conflict'
|
||||
? `${it.path} already exists in ${to}, marked it workspace-specific instead of overwriting`
|
||||
: `Created ${it.path} in ${to}`
|
||||
)
|
||||
await loadPinned()
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to create ${it.path}: ${e}`, true)
|
||||
} finally {
|
||||
pinBusy[k] = false
|
||||
}
|
||||
}
|
||||
|
||||
// A fork row has a pending draft when its key is in the page-provided set.
|
||||
function hasDraft(diff: WorkspaceItemDiff): boolean {
|
||||
return draftKeys.has(getItemKey(diff))
|
||||
@@ -1016,6 +1255,27 @@
|
||||
Show diff
|
||||
</Button>
|
||||
</div>
|
||||
{#if diff.kind === 'resource' || diff.kind === 'variable'}
|
||||
{#if isPinned(diff.kind, diff.path)}
|
||||
<Badge
|
||||
color="gray"
|
||||
size="xs"
|
||||
title="Kept per environment, so it's excluded from the diff. Seed a missing copy or revert it in the Workspace-specific items list below."
|
||||
>
|
||||
workspace-specific
|
||||
</Badge>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
disabled={pinBusy[key]}
|
||||
title="Keep this item's value per environment (excluded from the diff). Seed a missing copy from the Workspace-specific items list."
|
||||
onClick={() => setPinned(diff.kind as 'resource' | 'variable', diff.path, true)}
|
||||
>
|
||||
Make workspace specific
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1097,6 +1357,53 @@
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
|
||||
</div>
|
||||
|
||||
{#if pinnedItems.length > 0}
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1.5 font-semibold text-secondary text-sm">
|
||||
Workspace-specific items
|
||||
</div>
|
||||
<p class="text-xs text-tertiary">
|
||||
These resources and variables keep their own value in each environment and are excluded
|
||||
from the diff. An item that exists on only one side can be seeded onto the other with
|
||||
"Create in …" (copies the current value, including secrets); it will never overwrite an
|
||||
existing value.
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each pinnedItems as it (`${it.item_kind}:${it.path}`)}
|
||||
{@const missingSide =
|
||||
it.onCurrent && !it.onParent
|
||||
? parentWorkspaceId
|
||||
: !it.onCurrent && it.onParent
|
||||
? currentWorkspaceId
|
||||
: undefined}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Badge color="transparent" small>{it.item_kind}</Badge>
|
||||
<span class="text-secondary font-mono text-xs flex-1 truncate">{it.path}</span>
|
||||
{#if missingSide}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
disabled={pinBusy[`${it.item_kind}:${it.path}`]}
|
||||
title="Copy this item's current value (including secrets) into {missingSide}"
|
||||
onClick={() => (createConfirm = it)}
|
||||
>
|
||||
Create in {missingSide}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
disabled={pinBusy[`${it.item_kind}:${it.path}`]}
|
||||
onClick={() => setPinned(it.item_kind as 'resource' | 'variable', it.path, false)}
|
||||
>
|
||||
Make shared
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
||||
@@ -1129,6 +1436,27 @@
|
||||
</ul>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
open={!!createConfirm}
|
||||
title="Create in {createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}?"
|
||||
confirmationText="Create"
|
||||
onConfirmed={() => {
|
||||
const it = createConfirm
|
||||
createConfirm = undefined
|
||||
if (it) createOnRemote(it)
|
||||
}}
|
||||
onCanceled={() => (createConfirm = undefined)}
|
||||
>
|
||||
<p class="text-sm">
|
||||
This copies the current value of <span class="font-mono">{createConfirm?.path}</span>
|
||||
(including any secret value) from
|
||||
<b>{createConfirm?.onCurrent ? currentWorkspaceId : parentWorkspaceId}</b>
|
||||
into <b>{createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}</b>. It stays
|
||||
workspace-specific afterward, so later promotes won't overwrite it. If it already exists
|
||||
there, it's left untouched and just marked workspace-specific.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-gray-500">No comparison data available</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces, usersWorkspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { goto } from '$app/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { GitFork, ExternalLink } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
|
||||
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let isDev = $derived(currentWs?.is_dev_workspace ?? false)
|
||||
let parentId = $derived(currentWs?.parent_workspace_id ?? undefined)
|
||||
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
|
||||
|
||||
// A prod admin who isn't a member of the dev can't see it in their workspace list, so ask the
|
||||
// server (only when the client list doesn't already have it) — otherwise the tab would show the
|
||||
// attach form instead of the existing pairing and detach control.
|
||||
const devWorkspaceResource = resource(
|
||||
() => (!isDev && !parentId && !canonicalDev ? $workspaceStore : undefined),
|
||||
async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined)
|
||||
)
|
||||
// The paired dev to display: the client entry when we're a member (so "Go to dev workspace" works),
|
||||
// else the server result (pairing + detach still available to a prod admin).
|
||||
let pairedDev = $derived(
|
||||
canonicalDev
|
||||
? { id: canonicalDev.id, name: canonicalDev.name, isMember: true }
|
||||
: devWorkspaceResource.current
|
||||
? {
|
||||
id: devWorkspaceResource.current.id,
|
||||
name: devWorkspaceResource.current.name,
|
||||
isMember: false
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
let selectedDevId = $state<string | undefined>(undefined)
|
||||
let lockProdDeploy = $state(true)
|
||||
let lockProdForking = $state(true)
|
||||
let busy = $state(false)
|
||||
|
||||
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
|
||||
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
|
||||
// prod), so it's excluded here.
|
||||
let attachCandidates = $derived(
|
||||
$userWorkspaces
|
||||
.filter(
|
||||
(w) =>
|
||||
w.id !== $workspaceStore &&
|
||||
w.id !== 'admins' &&
|
||||
(!w.parent_workspace_id || w.parent_workspace_id === $workspaceStore)
|
||||
)
|
||||
.map((w) => ({
|
||||
label:
|
||||
w.parent_workspace_id === $workspaceStore
|
||||
? `${w.name} (${w.id}), fork of this workspace`
|
||||
: `${w.name} (${w.id})`,
|
||||
value: w.id
|
||||
}))
|
||||
)
|
||||
|
||||
async function refresh() {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
// Attach/detach changes this (root) workspace's protection rules; reload them so the
|
||||
// direct-deploy / forking lock UI reflects the change without a workspace switch or reload.
|
||||
if ($workspaceStore) {
|
||||
await loadProtectionRules($workspaceStore)
|
||||
}
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
if (!selectedDevId || !$workspaceStore) return
|
||||
busy = true
|
||||
try {
|
||||
await WorkspaceService.attachDevWorkspace({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
dev_workspace_id: selectedDevId,
|
||||
lock_prod_deploy: lockProdDeploy,
|
||||
lock_prod_forking: lockProdForking
|
||||
}
|
||||
})
|
||||
sendUserToast(`Attached ${selectedDevId} as dev workspace`)
|
||||
selectedDevId = undefined
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to attach dev workspace: ${e?.body ?? e}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function detach(devId: string) {
|
||||
if (!$workspaceStore) return
|
||||
busy = true
|
||||
try {
|
||||
await WorkspaceService.detachDevWorkspace({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { dev_workspace_id: devId }
|
||||
})
|
||||
sendUserToast(`Detached dev workspace ${devId}`)
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to detach dev workspace: ${e?.body ?? e}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isDev && parentId}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This is a <b>dev workspace</b> paired with root workspace <b>{parentId}</b>. Promote changes
|
||||
from the home page banner or the Compare & Deploy page.
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
onclick={() => switchWorkspace(parentId)}
|
||||
>
|
||||
Go to root workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if pairedDev}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This workspace's dev workspace is <b>{pairedDev.name}</b> ({pairedDev.id}). Edits to this
|
||||
workspace are redirected there.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if pairedDev.isMember}
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => switchWorkspace(pairedDev.id)}
|
||||
>
|
||||
Go to dev workspace
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if parentId}
|
||||
<p class="text-sm text-secondary max-w-2xl">
|
||||
Dev workspace pairing is only available for root workspaces. This workspace is a fork of
|
||||
<b>{parentId}</b>.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm text-secondary">
|
||||
Pair this workspace with a dev workspace: the same code with a different environment (resource
|
||||
and variable values). Edits are made in the dev workspace and promoted here.
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Attach an existing workspace as dev</span>
|
||||
<Select
|
||||
items={attachCandidates}
|
||||
bind:value={selectedDevId}
|
||||
placeholder="Select a workspace"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<Toggle
|
||||
bind:checked={lockProdDeploy}
|
||||
options={{
|
||||
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
|
||||
}}
|
||||
/>
|
||||
<Toggle bind:checked={lockProdForking} options={{ right: 'Prevent forking this workspace' }} />
|
||||
<div class="flex gap-2">
|
||||
<Button variant="accent" disabled={busy || !selectedDevId} onclick={attach}>
|
||||
Attach dev workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => goto(`${base}/user/fork_workspace`)}
|
||||
>
|
||||
Create a new dev workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -10,7 +10,13 @@
|
||||
type Job
|
||||
} from '$lib/gen'
|
||||
import { initHistory, redo, undo } from '$lib/history.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
usedTriggerKinds
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
generateRandomString,
|
||||
orderedJsonStringify,
|
||||
@@ -92,8 +98,7 @@
|
||||
import type { FlowBuilderProps } from './flow_builder'
|
||||
import { ModulesTestStates } from './modulesTest.svelte'
|
||||
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
@@ -732,9 +737,13 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (!untrack(() => newFlow) && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) {
|
||||
if (
|
||||
!untrack(() => newFlow) &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
) {
|
||||
dropdownItems.push({
|
||||
label: 'Edit in workspace fork',
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
onClick: () => window.open(buildForkEditUrl('flow', initialPath))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
||||
// A fork must have a parent to compare/merge against. Treating the wm-fork-
|
||||
// prefix alone as "is a fork" renders a parentless "Fork of ()" banner when
|
||||
// the parent linkage was dropped (e.g. by a workspace id change), so require
|
||||
// both, matching the forks/compare page.
|
||||
let isFork = $derived(($workspaceStore?.startsWith('wm-fork-') ?? false) && !!parentWorkspaceId)
|
||||
// Detect fork/dev workspaces by their parent link, not the `wm-fork-` id prefix (dev
|
||||
// workspaces have an ordinary, prefix-less id). Keying on the parent (rather than the
|
||||
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
|
||||
let isFork = $derived(parentWorkspaceId != null)
|
||||
let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false)
|
||||
|
||||
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
|
||||
// user with only pending drafts should still get the draft CTA (mirrors the
|
||||
@@ -170,7 +170,8 @@
|
||||
<GitFork class="w-4 h-4 text-accent" />
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-blue-900 dark:text-blue-100">
|
||||
Fork of <b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
|
||||
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
|
||||
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { userStore } from '$lib/stores'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
canUserBypassRuleKind,
|
||||
getActiveRulesetsForKind,
|
||||
isRuleActive
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { Alert } from './common'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Alert, Button } from './common'
|
||||
import { GitFork } from 'lucide-svelte'
|
||||
|
||||
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
|
||||
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
|
||||
let overrideChecked = $state(false)
|
||||
let canEdit = $derived(
|
||||
!isRuleActive('DisableDirectDeployment') || (canBypass && overrideChecked)
|
||||
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
|
||||
// Forking may itself be blocked by DisableWorkspaceForking, so only suggest it
|
||||
// when the user can actually fork this workspace.
|
||||
let canFork = $derived(canCreateFork($userStore))
|
||||
let editAdvice = $derived(
|
||||
canFork
|
||||
? 'You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.'
|
||||
: 'You will need to make your changes locally and submit a PR to an authorized user.'
|
||||
)
|
||||
let overrideChecked = $state(false)
|
||||
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || (canBypass && overrideChecked))
|
||||
|
||||
let {
|
||||
onUpdateCanEditStatus = (value) => {}
|
||||
@@ -27,12 +38,37 @@
|
||||
|
||||
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
|
||||
<div class="my-2">
|
||||
<Alert type="info" title="Workspace protection active">
|
||||
<Alert
|
||||
type="info"
|
||||
title={canonicalDev ? 'Edits happen in the dev workspace' : 'Workspace protection active'}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>
|
||||
The rule{activeDeployRulesets.length > 1 ? "s" : ""} <b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b> restrict{activeDeployRulesets.length > 1 ? "" : "s"} direct edits to
|
||||
this workspace. You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.
|
||||
</p>
|
||||
{#if canonicalDev}
|
||||
<p>
|
||||
Edits to this workspace are made in its dev workspace
|
||||
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
btnClasses="w-auto"
|
||||
size="xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => {
|
||||
if (canonicalDev) switchWorkspace(canonicalDev.id)
|
||||
}}
|
||||
>
|
||||
Go to dev workspace
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
The rule{activeDeployRulesets.length > 1 ? 's' : ''}
|
||||
<b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b>
|
||||
restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct edits to this workspace.
|
||||
{editAdvice}
|
||||
</p>
|
||||
{/if}
|
||||
{#if canBypass}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input class="rounded max-w-4" type="checkbox" bind:checked={overrideChecked} />
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { ProtectionRuleset } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { userStore, type UserExt } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import {
|
||||
fetchProtectionRulesForWorkspace,
|
||||
isRuleActiveInRulesets,
|
||||
canUserBypassRuleKindInRulesets,
|
||||
getActiveRulesetsForKindInRulesets,
|
||||
getActiveRulesetsForKindInRulesets
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { Alert } from './common'
|
||||
import { untrack } from 'svelte'
|
||||
import { resource } from 'runed'
|
||||
|
||||
let {
|
||||
parentWorkspaceId,
|
||||
@@ -18,32 +19,42 @@
|
||||
onUpdateCanDeploy?: (value: boolean) => void
|
||||
} = $props()
|
||||
|
||||
let parentRulesets = $state<ProtectionRuleset[]>([])
|
||||
let overrideChecked = $state(false)
|
||||
|
||||
// Fetch the parent's rules and the user's identity in the parent together. Both must come from the
|
||||
// parent: bypass is judged with is_admin/groups in the PARENT (per-workspace), not the active/fork
|
||||
// workspace's `$userStore`; getUserExt returns undefined for a non-member, treated as no bypass.
|
||||
const parentDataResource = resource(
|
||||
() => parentWorkspaceId,
|
||||
async (wsId) => {
|
||||
if (!wsId) return { rules: [] as ProtectionRuleset[], user: undefined as UserExt | undefined }
|
||||
const [rules, user] = await Promise.all([
|
||||
fetchProtectionRulesForWorkspace(wsId),
|
||||
getUserExt(wsId)
|
||||
])
|
||||
return { rules, user }
|
||||
}
|
||||
)
|
||||
let parentRulesets = $derived(parentDataResource.current?.rules ?? [])
|
||||
let parentUserInfo = $derived(parentDataResource.current?.user)
|
||||
|
||||
let activeDeployRulesets = $derived(
|
||||
getActiveRulesetsForKindInRulesets(parentRulesets, 'DisableDirectDeployment')
|
||||
)
|
||||
|
||||
let canBypass = $derived(
|
||||
canUserBypassRuleKindInRulesets(parentRulesets, 'DisableDirectDeployment', $userStore)
|
||||
canUserBypassRuleKindInRulesets(parentRulesets, 'DisableDirectDeployment', parentUserInfo)
|
||||
)
|
||||
|
||||
// Block deploy until the parent's rules/identity have loaded: while loading `parentRulesets` is
|
||||
// empty, which would otherwise read as "no lock" and briefly enable deploy before the real
|
||||
// lock/bypass check resolves.
|
||||
let canDeploy = $derived(
|
||||
!isRuleActiveInRulesets(parentRulesets, 'DisableDirectDeployment') ||
|
||||
(canBypass && overrideChecked)
|
||||
!parentDataResource.loading &&
|
||||
(!isRuleActiveInRulesets(parentRulesets, 'DisableDirectDeployment') ||
|
||||
(canBypass && overrideChecked))
|
||||
)
|
||||
|
||||
// Fetch parent workspace rules
|
||||
$effect(() => {
|
||||
if (parentWorkspaceId) {
|
||||
untrack(async () => {
|
||||
const rules = await fetchProtectionRulesForWorkspace(parentWorkspaceId)
|
||||
parentRulesets = rules
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Reset override when parent workspace changes
|
||||
$effect(() => {
|
||||
parentWorkspaceId
|
||||
@@ -60,9 +71,13 @@
|
||||
<Alert type="info" title="Parent workspace protection active" class="my-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>
|
||||
The workspace {parentWorkspaceId} has a protection rule{activeDeployRulesets.length > 1 ? 's' : ''}
|
||||
The workspace {parentWorkspaceId} has a protection rule{activeDeployRulesets.length > 1
|
||||
? 's'
|
||||
: ''}
|
||||
<b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b>
|
||||
that restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct deployments. You need to merge changes through the synced git repo with Git Sync, or by asking a user with the rights to bypass this rule.
|
||||
that restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct deployments. You need to merge
|
||||
changes through the synced git repo with Git Sync, or by asking a user with the rights to bypass
|
||||
this rule.
|
||||
</p>
|
||||
{#if canBypass}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
enterpriseLicense,
|
||||
usedTriggerKinds,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workerTags,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
@@ -97,8 +98,7 @@
|
||||
import WorkerTagSelect from './WorkerTagSelect.svelte'
|
||||
import type { ButtonType } from './common/button/model'
|
||||
import DebounceLimit from './flows/DebounceLimit.svelte'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
|
||||
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -761,10 +761,10 @@
|
||||
window.open(`/scripts/add?template=${initialPath}`)
|
||||
}
|
||||
},
|
||||
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
|
||||
...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
? [
|
||||
{
|
||||
label: 'Edit in workspace fork',
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('script', initialPath))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { Button } from './common'
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
@@ -10,7 +11,7 @@
|
||||
// ForkWorkspaceBanner: that one self-gates on `isFork`, this one on `!isFork`,
|
||||
// so a fork workspace never shows both. In a fork, drafts are discovered via
|
||||
// the on-page "Deployed ↔ draft (N)" toggle badge instead.
|
||||
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
|
||||
let isFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces))
|
||||
|
||||
// Count comes from the shared Workspace Drafts resource (count ≡ the draft
|
||||
// list; refreshes itself on deploy/discard). Pass undefined in a fork or with
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
import { redo, undo } from '$lib/history.svelte'
|
||||
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { enterpriseLicense, tutorialsToDo, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
tutorialsToDo,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { isMac, type Item, userPathPrefix } from '$lib/utils'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
@@ -73,8 +79,7 @@
|
||||
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { updatePolicy } from './appPolicy'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -1132,10 +1137,10 @@
|
||||
window.open(`/apps/add?template=${appPath}`)
|
||||
}
|
||||
},
|
||||
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
|
||||
...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
? [
|
||||
{
|
||||
label: 'Edit in workspace fork',
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('app', $appPath))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { AppService, type ListableApp } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
@@ -34,8 +34,7 @@
|
||||
import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -170,7 +169,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !app.canWrite)}
|
||||
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)}
|
||||
<div>
|
||||
<Button
|
||||
variant={!showEditButton ? 'default' : 'subtle'}
|
||||
@@ -179,7 +178,7 @@
|
||||
startIcon={{ icon: GitFork }}
|
||||
href={buildForkEditUrl(app.raw_app ? 'raw_app' : 'app', app.path)}
|
||||
>
|
||||
Edit in fork
|
||||
{editInForkLabel($workspaceStore, $userWorkspaces)}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -235,10 +234,13 @@
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Edit in workspace fork',
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: GitFork,
|
||||
href: buildForkEditUrl(app.raw_app ? 'raw_app' : 'app', path),
|
||||
hide: $userStore?.operator || isCloudHosted() || isRuleActive('DisableWorkspaceForking')
|
||||
hide:
|
||||
$userStore?.operator ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
@@ -36,8 +36,7 @@
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -191,7 +190,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !flow.canWrite)}
|
||||
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
|
||||
<div>
|
||||
<Button
|
||||
variant={!showEditButton ? 'default' : 'subtle'}
|
||||
@@ -200,7 +199,7 @@
|
||||
startIcon={{ icon: GitFork }}
|
||||
href={buildForkEditUrl('flow', flow.path)}
|
||||
>
|
||||
Edit in fork
|
||||
{editInForkLabel($workspaceStore, $userWorkspaces)}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -252,10 +251,13 @@
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Edit in workspace fork',
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: GitFork,
|
||||
href: buildForkEditUrl('flow', path),
|
||||
hide: $userStore?.operator || isCloudHosted() || isRuleActive('DisableWorkspaceForking')
|
||||
hide:
|
||||
$userStore?.operator ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import { hubBaseUrlStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@@ -48,8 +48,7 @@
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { scriptToHubUrl } from '$lib/hub'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -251,7 +250,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && (!showEditButton || !script.canWrite)}
|
||||
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)}
|
||||
<div>
|
||||
<Button
|
||||
variant={!showEditButton ? 'default' : 'subtle'}
|
||||
@@ -260,7 +259,7 @@
|
||||
startIcon={{ icon: GitFork }}
|
||||
href={buildForkEditUrl('script', script.path)}
|
||||
>
|
||||
Edit in fork
|
||||
{editInForkLabel($workspaceStore, $userWorkspaces)}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -334,10 +333,13 @@
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Edit in workspace fork',
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: GitFork,
|
||||
href: buildForkEditUrl('script', script.path),
|
||||
hide: $userStore?.operator || isCloudHosted() || isRuleActive('DisableWorkspaceForking')
|
||||
hide:
|
||||
$userStore?.operator ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
hubBaseUrlStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import YAML from 'yaml'
|
||||
import {
|
||||
Bug,
|
||||
@@ -70,8 +76,7 @@
|
||||
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
|
||||
import { stripRawAppDiffNoise } from './utils'
|
||||
import type { RawAppData } from './dataTableRefUtils'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
// async function hash(message) {
|
||||
@@ -890,10 +895,10 @@
|
||||
window.open(`/apps/add?template=${appPath}`)
|
||||
}
|
||||
},
|
||||
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
|
||||
...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
? [
|
||||
{
|
||||
label: 'Edit in workspace fork',
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('raw_app', appPath))
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte'
|
||||
import { getRuntime } from './sessionRuntime.svelte'
|
||||
@@ -54,7 +54,7 @@
|
||||
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
|
||||
// When forking isn't available the diff/review surface is moot.
|
||||
const forksAllowed = $derived(
|
||||
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
|
||||
!isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins'
|
||||
)
|
||||
|
||||
let diffDrawer: ForkDiffDrawer | undefined = $state(undefined)
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte'
|
||||
@@ -287,9 +288,12 @@
|
||||
// Fork workspace tied to `pendingDelete`, if any, and still accessible.
|
||||
const pendingDeleteForkId = $derived.by(() => {
|
||||
const wsId = pendingDelete?.workspace_id
|
||||
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
|
||||
if (!wsId) return undefined
|
||||
const ws = $userWorkspaces.find((w) => w.id === wsId)
|
||||
if (!ws || !ws.parent_workspace_id) return undefined
|
||||
// Fork = prefix OR parent (so an orphaned wm-fork- fork still qualifies); exclude persistent
|
||||
// dev workspaces, which are not ephemeral session forks.
|
||||
if (!ws || !workspaceIsFork(wsId, $userWorkspaces)) return undefined
|
||||
if (ws.is_dev_workspace) return undefined
|
||||
return wsId
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
type Session
|
||||
} from './sessionState.svelte'
|
||||
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { Building, ChevronDown, GitFork } from 'lucide-svelte'
|
||||
|
||||
let { session }: { session: Session } = $props()
|
||||
@@ -65,6 +66,9 @@
|
||||
<span class="font-medium text-primary truncate max-w-[180px]">
|
||||
{pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'}
|
||||
</span>
|
||||
{#if !pendingFork && currentWs?.is_dev_workspace}
|
||||
<Badge color="indigo" small>dev</Badge>
|
||||
{/if}
|
||||
{#if pendingFork}
|
||||
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
|
||||
{/if}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
@@ -92,10 +93,13 @@
|
||||
// the fork lingers as an orphan whose only purpose was this session.
|
||||
const sessionForkId = $derived.by(() => {
|
||||
const wsId = session?.workspace_id
|
||||
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
|
||||
if (!wsId) return undefined
|
||||
const ws = $userWorkspaces.find((w) => w.id === wsId)
|
||||
// Don't offer the option if the fork is gone or not user-accessible.
|
||||
if (!ws || !ws.parent_workspace_id) return undefined
|
||||
// Don't offer the option if the fork is gone/not user-accessible or isn't a fork (prefix OR
|
||||
// parent, so an orphaned wm-fork- fork still qualifies).
|
||||
if (!ws || !workspaceIsFork(wsId, $userWorkspaces)) return undefined
|
||||
// A persistent dev workspace is not an ephemeral session fork — never offer to delete it.
|
||||
if (ws.is_dev_workspace) return undefined
|
||||
return wsId
|
||||
})
|
||||
|
||||
|
||||
@@ -2,20 +2,32 @@
|
||||
import { tick, type Snippet } from 'svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
type UserWorkspace
|
||||
} from '$lib/stores'
|
||||
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import {
|
||||
findWorkspaceDescendants,
|
||||
findCanonicalDevWorkspace
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import {
|
||||
fetchProtectionRulesForWorkspace,
|
||||
isRuleActiveInRulesets,
|
||||
canUserBypassRuleKindInRulesets
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import InputError from '$lib/components/InputError.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { Building, Check, GitFork, Plus } from 'lucide-svelte'
|
||||
|
||||
type PendingFork = { id: string; name: string }
|
||||
type PendingFork = { parent_workspace_id: string; id: string; name: string }
|
||||
type ForkRequest = { parent_workspace_id: string; id: string; name: string }
|
||||
|
||||
let {
|
||||
@@ -61,11 +73,53 @@
|
||||
const root = $derived(findRoot(effectiveId, $userWorkspaces))
|
||||
const forks = $derived(root ? findWorkspaceDescendants(root.id, $userWorkspaces) : [])
|
||||
|
||||
// Structural gate, same as the sidebar WorkspaceMenu / SessionWorkspaceBar:
|
||||
// when closed (cloud / DisableWorkspaceForking rule / admins workspace) the
|
||||
// fork affordance is hidden entirely.
|
||||
// New forks derive from the editable dev workspace when the family has one:
|
||||
// the root (prod) is typically forking-locked, so a non-admin can't fork it,
|
||||
// and dev holds the current code anyway. Falls back to the root otherwise.
|
||||
const devOfRoot = $derived(root ? findCanonicalDevWorkspace(root.id, $userWorkspaces) : undefined)
|
||||
const forkSource = $derived(devOfRoot ?? root)
|
||||
const createForkLabel = $derived(devOfRoot ? `Fork from ${devOfRoot.name}` : 'Create new fork…')
|
||||
|
||||
// Judge the prod root off its OWN protection rules (fetched), not the active
|
||||
// workspace's — so it reads correctly from a dev/fork too, the same way
|
||||
// ParentWorkspaceProtectionAlert checks the parent. It's selectable when you
|
||||
// can deploy to it, and "Fork from prod" shows when you can fork it.
|
||||
const rootRulesetsResource = resource(
|
||||
() => root?.id,
|
||||
async (id) => (id ? await fetchProtectionRulesForWorkspace(id) : [])
|
||||
)
|
||||
const rootRulesets = $derived(rootRulesetsResource.current ?? [])
|
||||
// Bypass must be judged with the user's identity IN THE ROOT (is_admin/groups are per-workspace),
|
||||
// not the active workspace's `$userStore` — otherwise an admin of the dev/active workspace who is a
|
||||
// non-admin of the root would get a false bypass. `getUserExt` returns undefined for a non-member,
|
||||
// which `canUserBypassRuleKindInRulesets` treats as no bypass.
|
||||
const rootUserInfoResource = resource(
|
||||
() => root?.id,
|
||||
async (id) => (id ? await getUserExt(id) : undefined)
|
||||
)
|
||||
const rootUserInfo = $derived(rootUserInfoResource.current)
|
||||
const canDeployRoot = $derived(
|
||||
!isRuleActiveInRulesets(rootRulesets, 'DisableDirectDeployment') ||
|
||||
canUserBypassRuleKindInRulesets(rootRulesets, 'DisableDirectDeployment', rootUserInfo)
|
||||
)
|
||||
const canForkRoot = $derived(
|
||||
!isRuleActiveInRulesets(rootRulesets, 'DisableWorkspaceForking') ||
|
||||
canUserBypassRuleKindInRulesets(rootRulesets, 'DisableWorkspaceForking', rootUserInfo)
|
||||
)
|
||||
// A genuinely deploy-locked root (you can't deploy and can't bypass) is disabled regardless of
|
||||
// whether a dev workspace exists to steer to — being deploy-locked is the gate, not dev presence.
|
||||
// Roots with no rules resolve `canDeployRoot` to true, so ordinary families aren't affected. Kept
|
||||
// disabled while either fetch is in flight (both default to the conservative locked state).
|
||||
const rootDisabled = $derived(
|
||||
rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot
|
||||
)
|
||||
|
||||
// Structural gate: hidden on cloud, in the admins workspace, or when the user
|
||||
// can't fork. DisableWorkspaceForking on the active workspace (a locked prod)
|
||||
// doesn't apply when there's a dev to fork from instead — the dev isn't
|
||||
// locked, and devOfRoot only resolves when the user is a member of it.
|
||||
const forksGateOpen = $derived(
|
||||
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
|
||||
!isCloudHosted() && $workspaceStore !== 'admins' && (canCreateFork($userStore) || !!devOfRoot)
|
||||
)
|
||||
// A fork is a new workspace, so it's subject to the community-edition cap on
|
||||
// the number of non-'admins' workspaces (backend _check_nb_of_workspaces,
|
||||
@@ -84,18 +138,39 @@
|
||||
const forkAffordanceOpen = $derived(allowCreateFork && forksGateOpen && !!onCreateFork && !!root)
|
||||
const showCreateFork = $derived(forkAffordanceOpen && !ceWorkspaceCapReached)
|
||||
const showForkUpsell = $derived(forkAffordanceOpen && ceWorkspaceCapReached)
|
||||
// Whether the second "Fork from <root>" create entry is shown (admins can fork the prod root
|
||||
// directly even when the default entry forks the dev). Mirrors its render condition so keyboard
|
||||
// nav and the row index math stay in sync. Suppressed while the root's rules are still loading,
|
||||
// otherwise `canForkRoot` defaults true and a non-bypass user could stage a fork from a
|
||||
// forking-locked root before the rules resolve.
|
||||
const hasCreateFromRoot = $derived(
|
||||
showCreateFork &&
|
||||
!rootRulesetsResource.loading &&
|
||||
!rootUserInfoResource.loading &&
|
||||
canForkRoot &&
|
||||
!!devOfRoot
|
||||
)
|
||||
|
||||
let dropdownOpen = $state(false)
|
||||
let creatingFork = $state(false)
|
||||
let newForkName = $state('')
|
||||
let forkInput: TextInput | undefined = $state(undefined)
|
||||
// Admins get a second entry to fork the prod root directly (the default forks
|
||||
// the dev). `forkFromRoot` tracks which source the create input is for.
|
||||
let forkFromRoot = $state(false)
|
||||
const effectiveForkSource = $derived(forkFromRoot ? root : forkSource)
|
||||
|
||||
// Manual keyboard navigation, modelled after SelectDropdown. melt's
|
||||
// menu API couples Enter/Space to closing the menu, which we explicitly
|
||||
// don't want for the "Create new fork" row — it swaps to inline input.
|
||||
type NavRow = { kind: 'create' } | { kind: 'root'; id: string } | { kind: 'fork'; id: string }
|
||||
type NavRow =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'create-from-root' }
|
||||
| { kind: 'root'; id: string }
|
||||
| { kind: 'fork'; id: string }
|
||||
const navRows = $derived<NavRow[]>([
|
||||
...(showCreateFork ? [{ kind: 'create' as const }] : []),
|
||||
...(hasCreateFromRoot ? [{ kind: 'create-from-root' as const }] : []),
|
||||
...(root ? [{ kind: 'root' as const, id: root.id }] : []),
|
||||
...forks.map((f) => ({ kind: 'fork' as const, id: f.id }))
|
||||
])
|
||||
@@ -107,7 +182,11 @@
|
||||
function activateRow(row: NavRow) {
|
||||
if (row.kind === 'create') {
|
||||
void enterCreateMode()
|
||||
} else if (row.kind === 'root' || row.kind === 'fork') {
|
||||
} else if (row.kind === 'create-from-root') {
|
||||
void enterCreateMode(undefined, true)
|
||||
} else if (row.kind === 'root') {
|
||||
if (!rootDisabled) void pick(row.id)
|
||||
} else if (row.kind === 'fork') {
|
||||
void pick(row.id)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +210,8 @@
|
||||
await onPick(id)
|
||||
}
|
||||
|
||||
async function enterCreateMode(initialName?: string) {
|
||||
async function enterCreateMode(initialName?: string, fromRoot = false) {
|
||||
forkFromRoot = fromRoot
|
||||
creatingFork = true
|
||||
newForkName = initialName ?? defaultForkName()
|
||||
await tick()
|
||||
@@ -142,6 +222,7 @@
|
||||
function cancelCreate() {
|
||||
creatingFork = false
|
||||
newForkName = ''
|
||||
forkFromRoot = false
|
||||
}
|
||||
|
||||
function slugForkBaseId(name: string): string {
|
||||
@@ -166,15 +247,16 @@
|
||||
|
||||
async function stageNewFork() {
|
||||
const name = newForkName.trim()
|
||||
if (!root || !name || forkNameError || !onCreateFork) return
|
||||
if (!effectiveForkSource || !name || forkNameError || !onCreateFork) return
|
||||
const baseId = slugForkBaseId(name)
|
||||
if (!baseId) return
|
||||
const prefixed = `${WM_FORK_PREFIX}${baseId}`
|
||||
// Close optimistically; consumer can re-open + toast on error.
|
||||
creatingFork = false
|
||||
newForkName = ''
|
||||
forkFromRoot = false
|
||||
dropdownOpen = false
|
||||
await onCreateFork({ parent_workspace_id: root.id, id: prefixed, name })
|
||||
await onCreateFork({ parent_workspace_id: effectiveForkSource.id, id: prefixed, name })
|
||||
}
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
@@ -190,7 +272,9 @@
|
||||
const wasOpen = lastDropdownOpen
|
||||
lastDropdownOpen = dropdownOpen
|
||||
if (dropdownOpen && !wasOpen && pendingFork && !creatingFork && showCreateFork) {
|
||||
void enterCreateMode(pendingFork.name)
|
||||
// Preserve which source the fork was staged from (root vs dev); without this the re-entry
|
||||
// defaults to the dev and silently re-parents a "Fork from <root>" request.
|
||||
void enterCreateMode(pendingFork.name, pendingFork.parent_workspace_id === root?.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -290,8 +374,20 @@
|
||||
onclick={() => enterCreateMode()}
|
||||
>
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<span>Create new fork…</span>
|
||||
<span>{createForkLabel}</span>
|
||||
</button>
|
||||
{#if hasCreateFromRoot}
|
||||
{@const createFromRootIdx = 1}
|
||||
<button
|
||||
type="button"
|
||||
class={`${rowBase} ${keyArrowPos === createFromRootIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
onmouseenter={() => (keyArrowPos = createFromRootIdx)}
|
||||
onclick={() => enterCreateMode(undefined, true)}
|
||||
>
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<span>Fork from {root?.name}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
{:else if showForkUpsell}
|
||||
@@ -302,27 +398,36 @@
|
||||
1} workspaces. Archive a workspace or upgrade to an enterprise license to create more forks."
|
||||
>
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<span>Create new fork…</span>
|
||||
<span>{createForkLabel}</span>
|
||||
<span class="ml-auto shrink-0 text-2xs text-tertiary"> Workspace limit reached </span>
|
||||
</div>
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
{#if root}
|
||||
{@const rootIdx = showCreateFork ? 1 : 0}
|
||||
{@const rootIdx = (showCreateFork ? 1 : 0) + (hasCreateFromRoot ? 1 : 0)}
|
||||
<button
|
||||
type="button"
|
||||
class={`${rowBase} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${keyArrowPos === rootIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
onmouseenter={() => (keyArrowPos = rootIdx)}
|
||||
onclick={() => void pick(root.id)}
|
||||
disabled={rootDisabled}
|
||||
title={rootDisabled
|
||||
? devOfRoot
|
||||
? `${root.name} is locked. Run in its dev workspace instead.`
|
||||
: `${root.name} is locked for direct deploys.`
|
||||
: undefined}
|
||||
class={`${rowBase} ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`}
|
||||
onmouseenter={() => !rootDisabled && (keyArrowPos = rootIdx)}
|
||||
onclick={() => !rootDisabled && void pick(root.id)}
|
||||
>
|
||||
<Building size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{root.name}</span>
|
||||
<span class="text-2xs text-tertiary shrink-0 ml-auto">root</span>
|
||||
<span class="text-2xs text-tertiary shrink-0 ml-auto"
|
||||
>{rootDisabled ? 'locked' : 'root'}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{#each forks as f, fi (f.id)}
|
||||
{@const forkIdx = (showCreateFork ? 1 : 0) + (root ? 1 : 0) + fi}
|
||||
{@const forkIdx =
|
||||
(showCreateFork ? 1 : 0) + (hasCreateFromRoot ? 1 : 0) + (root ? 1 : 0) + fi}
|
||||
<button
|
||||
type="button"
|
||||
class={`${rowBase} ${isSelected(f.id) ? 'bg-surface-selected' : ''} ${keyArrowPos === forkIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
@@ -331,6 +436,9 @@
|
||||
>
|
||||
<GitFork size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{f.name}</span>
|
||||
{#if f.is_dev_workspace}
|
||||
<Badge color="indigo" small>dev</Badge>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if pendingFork && !creatingFork}
|
||||
|
||||
@@ -4,12 +4,19 @@ import { createLongHash } from '$lib/editorLangUtils'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
usersWorkspaceStore,
|
||||
workspaceStore,
|
||||
type UserWorkspace
|
||||
} from '$lib/stores'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import {
|
||||
isRuleActive,
|
||||
canUserBypassRuleKind,
|
||||
protectionRulesState
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { workspaceRootId } from './sessionScope.svelte'
|
||||
import { type DBSchema, type IDBPDatabase } from 'idb'
|
||||
@@ -552,15 +559,6 @@ export function findSessionByName(name: string): Session | undefined {
|
||||
return sessionState.sessions.find((s) => s.name === name)
|
||||
}
|
||||
|
||||
function defaultSessionWorkspaceId(
|
||||
id: string | undefined,
|
||||
all: UserWorkspace[]
|
||||
): string | undefined {
|
||||
const root = workspaceRootId(id, all)
|
||||
if (root && all.some((w) => w.id === root)) return root
|
||||
return id
|
||||
}
|
||||
|
||||
export function createSession(): Session {
|
||||
// Reuse the existing transient session (if any) so the user can hit
|
||||
// the "+" button repeatedly without piling drafts. The transient
|
||||
@@ -574,12 +572,24 @@ export function createSession(): Session {
|
||||
.map((s) => /^session-(\d+)$/.exec(s.name)?.[1])
|
||||
.map((n) => (n ? parseInt(n, 10) : 0))
|
||||
const next = (existingNumbers.length ? Math.max(...existingNumbers) : 0) + 1
|
||||
// Default to the root workspace rather than wherever the user happens
|
||||
// to be — sessions usually start from "the canonical workspace" and
|
||||
// the picker lets them switch to a fork later.
|
||||
// Start in the workspace you're in. The one exception: a root you can't
|
||||
// deploy to (locked, no bypass) steers to its dev, since a session there
|
||||
// couldn't edit anything. The picker lets you switch.
|
||||
const currentWs = get(workspaceStore)
|
||||
const root = defaultSessionWorkspaceId(currentWs ?? undefined, get(userWorkspaces))
|
||||
const pending = root ?? currentWs
|
||||
const devOfCurrent = currentWs
|
||||
? findCanonicalDevWorkspace(currentWs, get(userWorkspaces))?.id
|
||||
: undefined
|
||||
// Only trust the deploy check once the active workspace's rules have actually loaded: until then
|
||||
// `isRuleActive` reads an empty ruleset and fails open, which would default a new session onto a
|
||||
// locked prod. Treat "not yet loaded for currentWs" as not-deployable so we steer to the dev (always
|
||||
// editable) when one exists; the picker still lets the user switch back once rules resolve.
|
||||
const rulesLoadedForCurrent =
|
||||
protectionRulesState.rulesets !== undefined && protectionRulesState.workspace === currentWs
|
||||
const canDeployHere =
|
||||
rulesLoadedForCurrent &&
|
||||
(!isRuleActive('DisableDirectDeployment') ||
|
||||
canUserBypassRuleKind('DisableDirectDeployment', get(userStore)))
|
||||
const pending = devOfCurrent && !canDeployHere ? devOfCurrent : currentWs
|
||||
// Friendly default summary so the header reads like "Zippy session"
|
||||
// rather than "Untitled session" — assigned at create time, the user
|
||||
// can still rename it (or it gets overwritten by an editor target).
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
tutorialsToDo,
|
||||
skippedAll
|
||||
} from '$lib/stores'
|
||||
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
|
||||
import { findWorkspaceDescendants, workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { syncTutorialsTodos } from '$lib/tutorialUtils'
|
||||
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
|
||||
import {
|
||||
@@ -187,6 +187,8 @@
|
||||
const forkedDescendants = $derived(
|
||||
$workspaceStore ? findWorkspaceDescendants($workspaceStore, $userWorkspaces ?? []) : []
|
||||
)
|
||||
// Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
|
||||
const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
|
||||
|
||||
let hasNewChangelogs = $state(false)
|
||||
let recentChangelogs: Changelog[] = $state([])
|
||||
@@ -550,7 +552,7 @@
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...($workspaceStore?.startsWith('wm-fork-')
|
||||
...(currentWsIsFork
|
||||
? [
|
||||
{
|
||||
label: 'Delete Forked Workspace',
|
||||
@@ -884,7 +886,7 @@
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
{#if $workspaceStore?.startsWith('wm-fork-')}
|
||||
{#if currentWsIsFork}
|
||||
<ConfirmationModal
|
||||
open={deleteWorkspaceForkModal}
|
||||
title="Delete forked workspace"
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
clearWorkspaceFromStorage,
|
||||
globalForkModal
|
||||
} from '$lib/stores'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { Menu, MenuItem } from '$lib/components/meltComponents'
|
||||
@@ -131,7 +132,9 @@
|
||||
icon={GitFork}
|
||||
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
|
||||
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
|
||||
sublabel={parentWorkspace?.name ? `Fork of ${parentWorkspace.name}` : undefined}
|
||||
sublabel={parentWorkspace?.name
|
||||
? `${forkedWorkspace.is_dev_workspace ? 'Dev workspace of' : 'Fork of'} ${parentWorkspace.name}`
|
||||
: undefined}
|
||||
{isCollapsed}
|
||||
color={$workspaceColor}
|
||||
{trigger}
|
||||
@@ -170,16 +173,26 @@
|
||||
>
|
||||
<div class="flex items-center justify-between min-w-0 w-full">
|
||||
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
|
||||
<WorkspaceIcon workspaceColor={workspace.color} {isForked} {parentName} />
|
||||
<WorkspaceIcon
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
isDevWorkspace={workspace.is_dev_workspace}
|
||||
{parentName}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class={twMerge(
|
||||
'truncate text-left text-xs font-normal',
|
||||
isSelected ? 'text-accent' : 'text-primary'
|
||||
)}
|
||||
title={workspace.name}
|
||||
>
|
||||
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class={twMerge(
|
||||
'truncate text-left text-xs font-normal',
|
||||
isSelected ? 'text-accent' : 'text-primary'
|
||||
)}
|
||||
title={workspace.name}
|
||||
>
|
||||
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
|
||||
</div>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge color="indigo">dev</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -204,7 +217,7 @@
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins'}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
const paddingLeft = untrack(() => depth) * 24
|
||||
const isSelected = $derived(selectedWorkspaceId === workspace.id)
|
||||
|
||||
// The canonical dev workspace sorts before throwaway forks.
|
||||
const sortedChildren = $derived(
|
||||
[...children].sort((a, b) => {
|
||||
if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
)
|
||||
|
||||
// Helper functions
|
||||
function isWorkspaceArchived(workspace: UserWorkspace): boolean {
|
||||
return workspace['deleted'] === true
|
||||
@@ -111,6 +119,7 @@
|
||||
<WorkspaceIcon
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
isDevWorkspace={workspace.is_dev_workspace}
|
||||
parentName={workspace.parent_workspace_id ?? undefined}
|
||||
size={12}
|
||||
/>
|
||||
@@ -125,6 +134,9 @@
|
||||
{workspace.name}
|
||||
{/if}
|
||||
</span>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge color="indigo">dev</Badge>
|
||||
{/if}
|
||||
<span class="text-secondary text-xs">-</span>
|
||||
{#if workspace.id === 'admins'}
|
||||
<Badge color="blue">{workspace.id}</Badge>
|
||||
@@ -208,7 +220,7 @@
|
||||
<!-- Expanded forks -->
|
||||
{#if children.length > 0 && isExpanded}
|
||||
<div class="mt-2 ml-6" transition:slide={{ duration: 150 }}>
|
||||
{#each children as child (child.id)}
|
||||
{#each sortedChildren as child (child.id)}
|
||||
<WorkspaceCard
|
||||
workspace={child}
|
||||
isForked={true}
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
interface Props {
|
||||
workspaceColor?: string
|
||||
isForked?: boolean
|
||||
isDevWorkspace?: boolean
|
||||
parentName?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
let { workspaceColor, isForked = false, parentName, size = 14 }: Props = $props()
|
||||
let {
|
||||
workspaceColor,
|
||||
isForked = false,
|
||||
isDevWorkspace = false,
|
||||
parentName,
|
||||
size = 14
|
||||
}: Props = $props()
|
||||
|
||||
const iconColor = $derived(getContrastTextColor(workspaceColor))
|
||||
</script>
|
||||
@@ -20,7 +27,8 @@
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
{#if isForked && parentName}
|
||||
Fork of {parentName}
|
||||
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
|
||||
{parentName}
|
||||
{/if}
|
||||
{/snippet}
|
||||
<GitFork {size} class="flex-shrink-0" style="color: {iconColor}" />
|
||||
@@ -28,4 +36,4 @@
|
||||
{:else}
|
||||
<Building {size} style="color: {iconColor}" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,8 +41,15 @@
|
||||
|
||||
// Computed expansion states that include auto-expansion for search results
|
||||
let expansionStates = $derived.by(() => {
|
||||
// Prod nodes that have a canonical dev start expanded so the dev is visible without a click;
|
||||
// manual toggles still win, so the user can collapse them.
|
||||
const devExpanded: Record<string, boolean> = {}
|
||||
workspaces?.forEach((w) => {
|
||||
if (w.is_dev_workspace && w.parent_workspace_id) devExpanded[w.parent_workspace_id] = true
|
||||
})
|
||||
|
||||
if (!searchFilter || !filteredWorkspaces || !workspaces) {
|
||||
return manualExpansionStates
|
||||
return { ...devExpanded, ...manualExpansionStates }
|
||||
}
|
||||
|
||||
const matchedWorkspaceIds = new Set(filteredWorkspaces.map((w) => w.id))
|
||||
@@ -75,8 +82,8 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Combine manual and auto-expanded states
|
||||
return { ...manualExpansionStates, ...autoExpanded }
|
||||
// Combine dev-default, manual, and auto-expanded states
|
||||
return { ...devExpanded, ...manualExpansionStates, ...autoExpanded }
|
||||
})
|
||||
|
||||
// Build nested hierarchy correctly - always use full workspace list for hierarchy
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { logoutWithRedirect } from '$lib/logoutKit'
|
||||
import { page } from '$app/state'
|
||||
import { usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { resource } from 'runed'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
@@ -44,6 +46,41 @@
|
||||
|
||||
let { isFork = false, onFinish }: Props = $props()
|
||||
|
||||
// Dev-workspace mode: create the fork as a persistent, prefix-less dev workspace and (optionally)
|
||||
// lock the parent ("prod") against direct edits.
|
||||
let createAsDevWorkspace = $state(false)
|
||||
let lockProdDeploy = $state(true)
|
||||
let lockProdForking = $state(true)
|
||||
// Bring the parent's members into the fork (a shared env). Defaults on for a
|
||||
// dev workspace, off for a throwaway fork; flipping the dev toggle resets it.
|
||||
let copyMembers = $state(false)
|
||||
$effect(() => {
|
||||
copyMembers = createAsDevWorkspace
|
||||
})
|
||||
|
||||
// The dev-workspace option is only offered when forking a root workspace that doesn't already
|
||||
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
|
||||
let currentWorkspaceEntry = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
// Require the current workspace to be loaded before treating it as a root: a missing entry must
|
||||
// not read as root (it would offer invalid dev creation while the workspace list is still loading).
|
||||
// `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent
|
||||
// FK was set null — it has no parent but is still a fork, so it can't host a dev workspace.
|
||||
let currentIsRoot = $derived(
|
||||
!!currentWorkspaceEntry && !workspaceIsFork($workspaceStore, $userWorkspaces)
|
||||
)
|
||||
// Ask the server whether a dev already exists: the caller may not be a member of this prod's dev,
|
||||
// so the client workspace list can't see it and would offer an invalid "create dev" action.
|
||||
const devWorkspaceResource = resource(
|
||||
() => (currentIsRoot ? $workspaceStore : undefined),
|
||||
async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined)
|
||||
)
|
||||
// Offer dev designation only once the server confirms there's no dev yet (returns null); stay
|
||||
// conservative (no offer) while the check is loading (current is undefined).
|
||||
let canDesignateDevWorkspace = $derived(currentIsRoot && devWorkspaceResource.current === null)
|
||||
let currentWorkspaceName = $derived(
|
||||
currentWorkspaceEntry?.name ?? $workspaceStore ?? 'the root workspace'
|
||||
)
|
||||
|
||||
let id = $state('')
|
||||
let name = $state('')
|
||||
let username = $state('')
|
||||
@@ -73,12 +110,13 @@
|
||||
// For forks the actual workspace id is prefixed: checking the bare id
|
||||
// would report the name as free even when `wm-fork-<id>` is taken
|
||||
// (e.g. by an archived fork, which keeps its id reserved).
|
||||
const effectiveId = isFork ? `${WM_FORK_PREFIX}${id}` : id
|
||||
const effectiveId = isFork && !createAsDevWorkspace ? `${WM_FORK_PREFIX}${id}` : id
|
||||
let exists =
|
||||
id != '' && (await WorkspaceService.existsWorkspace({ requestBody: { id: effectiveId } }))
|
||||
forkIdTaken = isFork && exists
|
||||
// The "delete existing fork to reclaim the id" affordance is only for prefixed forks.
|
||||
forkIdTaken = isFork && !createAsDevWorkspace && exists
|
||||
if (exists) {
|
||||
errorId = isFork
|
||||
errorId = forkIdTaken
|
||||
? `A workspace with id '${effectiveId}' already exists. It may be an archived fork: archiving keeps the id reserved.`
|
||||
: 'ID already exists'
|
||||
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
|
||||
@@ -91,6 +129,9 @@
|
||||
|
||||
const WM_FORK_PREFIX = 'wm-fork-'
|
||||
|
||||
// A dev workspace keeps its bare id; an ordinary fork is prefixed with `wm-fork-`.
|
||||
const effectiveForkId = $derived(createAsDevWorkspace ? id : `${WM_FORK_PREFIX}${id}`)
|
||||
|
||||
let forkIdTaken = $state(false)
|
||||
let deleteExistingForkOpen = $state(false)
|
||||
let deletingExistingFork = $state(false)
|
||||
@@ -157,9 +198,8 @@
|
||||
}
|
||||
|
||||
async function createOrForkWorkspace() {
|
||||
const prefixed_id = `${WM_FORK_PREFIX}${id}`
|
||||
if (isFork) {
|
||||
await forkWorkspace(prefixed_id)
|
||||
await forkWorkspace(effectiveForkId)
|
||||
} else {
|
||||
await createWorkspace()
|
||||
}
|
||||
@@ -193,7 +233,13 @@
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
// Send the lock intent in this first phase too so the backend can reject a non-admin's
|
||||
// locked-dev request before any branch is created (avoids dangling branches).
|
||||
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
|
||||
lock_prod_forking: createAsDevWorkspace && lockProdForking,
|
||||
copy_members: copyMembers
|
||||
}
|
||||
})
|
||||
|
||||
@@ -246,7 +292,11 @@
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
forked_datatables: forkedDatatables
|
||||
forked_datatables: forkedDatatables,
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
|
||||
lock_prod_forking: createAsDevWorkspace && lockProdForking,
|
||||
copy_members: copyMembers
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -258,7 +308,11 @@
|
||||
}
|
||||
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
|
||||
sendUserToast(
|
||||
createAsDevWorkspace
|
||||
? `Created dev workspace ${effectiveForkId} for ${$workspaceStore}`
|
||||
: `Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`
|
||||
)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(prefixed_id)
|
||||
@@ -490,7 +544,7 @@
|
||||
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
||||
{/if}
|
||||
|
||||
{#if isFork}
|
||||
{#if isFork && !createAsDevWorkspace}
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
type="text"
|
||||
@@ -521,6 +575,46 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</label>
|
||||
{#if isFork && canDesignateDevWorkspace}
|
||||
<Label label="Persistent dev workspace">
|
||||
<span class="text-xs text-secondary">
|
||||
Create a standing dev workspace (no <code>wm-fork-</code> prefix) paired with this workspace,
|
||||
instead of a throwaway fork.
|
||||
</span>
|
||||
<div class="flex flex-col gap-2 pt-1">
|
||||
<Toggle bind:checked={createAsDevWorkspace} options={{ right: 'Dev workspace' }} />
|
||||
{#if createAsDevWorkspace}
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis"
|
||||
>Protect {currentWorkspaceName}</span
|
||||
>
|
||||
<span class="text-2xs text-secondary">
|
||||
Adds protection rules to this (root) workspace so changes are made in the new
|
||||
dev workspace and promoted here.
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
bind:checked={lockProdDeploy}
|
||||
options={{ right: 'Block direct edits (deploy via the dev workspace)' }}
|
||||
/>
|
||||
<Toggle bind:checked={lockProdForking} options={{ right: 'Prevent forking' }} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Label>
|
||||
{/if}
|
||||
{#if isFork && createAsDevWorkspace}
|
||||
<Label label="Members">
|
||||
<span class="text-xs text-secondary">
|
||||
Copy this workspace's members (and their group memberships) into the dev workspace so
|
||||
the team can work in it.
|
||||
</span>
|
||||
<div class="pt-1">
|
||||
<Toggle bind:checked={copyMembers} options={{ right: 'Copy members' }} />
|
||||
</div>
|
||||
</Label>
|
||||
{/if}
|
||||
<Label label="Workspace color">
|
||||
<span class="text-xs text-secondary">
|
||||
Color to identify the current workspace in the list of workspaces
|
||||
@@ -555,7 +649,7 @@
|
||||
<ForkDatatableSection
|
||||
bind:this={forkDatatableSection}
|
||||
onAllDone={() => {
|
||||
completeFork(`${WM_FORK_PREFIX}${id}`)
|
||||
completeFork(effectiveForkId)
|
||||
}}
|
||||
onCanceled={() => {
|
||||
forkCreationLoading = false
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface UserWorkspace {
|
||||
color?: string
|
||||
operator_settings?: OperatorSettings
|
||||
parent_workspace_id?: string | null
|
||||
is_dev_workspace?: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,89 @@
|
||||
import { base } from '$lib/base'
|
||||
import { get } from 'svelte/store'
|
||||
import {
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
type UserWorkspace,
|
||||
type UserExt
|
||||
} from '$lib/stores'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { isRuleActive, canUserBypassRuleKind } from '$lib/workspaceProtectionRules.svelte'
|
||||
|
||||
type ItemType = 'script' | 'flow' | 'app' | 'raw_app'
|
||||
|
||||
export function buildForkEditUrl(itemType: ItemType, itemPath: string): string {
|
||||
let editPath: string
|
||||
/**
|
||||
* Whether to show the "edit in fork / dev workspace" affordance. Allowed when forking isn't disabled,
|
||||
* when the user can bypass the forking rule (workspace admins, mirroring `canCreateFork`), OR when the
|
||||
* current workspace has a canonical dev to route to — routing into an existing dev workspace creates
|
||||
* no fork, so it survives a locked prod that has `DisableWorkspaceForking` set. User identity is read
|
||||
* non-reactively (it's stable within a session); reactivity comes from the workspace args.
|
||||
*/
|
||||
export function editInForkAllowed(
|
||||
currentWorkspaceId: string | undefined,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): boolean {
|
||||
return (
|
||||
!isRuleActive('DisableWorkspaceForking') ||
|
||||
canUserBypassRuleKind('DisableWorkspaceForking', get(userStore)) ||
|
||||
!!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
|
||||
)
|
||||
}
|
||||
|
||||
/** Label for the affordance: "Edit in <dev name>" when routed to a canonical dev, else "Edit in fork". */
|
||||
export function editInForkLabel(
|
||||
currentWorkspaceId: string | undefined,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): string {
|
||||
const dev = findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
|
||||
return dev ? `Edit in ${dev.name}` : 'Edit in fork'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user may CREATE a new fork of the current workspace: forking not disabled, or the user
|
||||
* can bypass the rule (workspace admins). Keeps the "Fork workspace" entry available to admins as the
|
||||
* last-resort escape hatch on a locked prod.
|
||||
*/
|
||||
export function canCreateFork(user: UserExt | undefined): boolean {
|
||||
return (
|
||||
!isRuleActive('DisableWorkspaceForking') ||
|
||||
canUserBypassRuleKind('DisableWorkspaceForking', user)
|
||||
)
|
||||
}
|
||||
|
||||
function editPathFor(itemType: ItemType, itemPath: string): string {
|
||||
switch (itemType) {
|
||||
case 'script':
|
||||
editPath = `${base}/scripts/edit/${itemPath}`
|
||||
break
|
||||
return `${base}/scripts/edit/${itemPath}`
|
||||
case 'flow':
|
||||
editPath = `${base}/flows/edit/${itemPath}`
|
||||
break
|
||||
return `${base}/flows/edit/${itemPath}`
|
||||
case 'app':
|
||||
editPath = `${base}/apps/edit/${itemPath}`
|
||||
break
|
||||
return `${base}/apps/edit/${itemPath}`
|
||||
case 'raw_app':
|
||||
editPath = `${base}/apps_raw/edit/${itemPath}`
|
||||
break
|
||||
return `${base}/apps_raw/edit/${itemPath}`
|
||||
}
|
||||
return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPath)}`
|
||||
}
|
||||
|
||||
function viewPathFor(itemType: ItemType, itemPath: string): string {
|
||||
switch (itemType) {
|
||||
case 'script':
|
||||
return `${base}/scripts/get/${itemPath}`
|
||||
case 'flow':
|
||||
return `${base}/flows/get/${itemPath}`
|
||||
case 'app':
|
||||
return `${base}/apps/get/${itemPath}`
|
||||
case 'raw_app':
|
||||
return `${base}/apps_raw/get/${itemPath}`
|
||||
}
|
||||
}
|
||||
|
||||
export function buildForkEditUrl(itemType: ItemType, itemPath: string): string {
|
||||
// When the current ("prod") workspace has a canonical dev workspace, edits are funneled there:
|
||||
// land on the item's page in the dev workspace (not straight in the editor) so the workspace
|
||||
// switch is legible and the user opens the editor deliberately from there.
|
||||
const dev = findCanonicalDevWorkspace(get(workspaceStore), get(userWorkspaces))
|
||||
if (dev) {
|
||||
return `${viewPathFor(itemType, itemPath)}?workspace=${encodeURIComponent(dev.id)}`
|
||||
}
|
||||
return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}`
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
}
|
||||
|
||||
// Create maps for quick lookups
|
||||
const workspaceMap = new Map(workspaces.map(w => [w.id, w]))
|
||||
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]))
|
||||
const childrenMap = new Map<string, UserWorkspace[]>()
|
||||
const hasChildrenSet = new Set<string>()
|
||||
|
||||
@@ -35,7 +35,7 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
}
|
||||
|
||||
// Find root workspaces (those without a parent or whose parent is not in the current list)
|
||||
const rootWorkspaces = workspaces.filter(w => {
|
||||
const rootWorkspaces = workspaces.filter((w) => {
|
||||
if (!w.parent_workspace_id) {
|
||||
return true // Definitely a root
|
||||
}
|
||||
@@ -46,7 +46,12 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
const result: WorkspaceHierarchyItem[] = []
|
||||
|
||||
// Recursively build the hierarchy
|
||||
function addWorkspaceAndChildren(workspace: UserWorkspace, depth: number, isForked: boolean, parentName?: string) {
|
||||
function addWorkspaceAndChildren(
|
||||
workspace: UserWorkspace,
|
||||
depth: number,
|
||||
isForked: boolean,
|
||||
parentName?: string
|
||||
) {
|
||||
// Add the current workspace
|
||||
result.push({
|
||||
workspace,
|
||||
@@ -56,11 +61,14 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
hasChildren: hasChildrenSet.has(workspace.id)
|
||||
})
|
||||
|
||||
// Add its children (sorted by name for consistency)
|
||||
// Add its children: the canonical dev workspace first, then throwaway forks by name.
|
||||
const children = childrenMap.get(workspace.id) || []
|
||||
children
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach(child => {
|
||||
.sort((a, b) => {
|
||||
if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.forEach((child) => {
|
||||
addWorkspaceAndChildren(child, depth + 1, true, workspace.name)
|
||||
})
|
||||
}
|
||||
@@ -68,12 +76,13 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
// Process root workspaces (sorted by name for consistency)
|
||||
rootWorkspaces
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach(workspace => {
|
||||
.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
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -95,11 +104,41 @@ export function isRootWorkspace(workspace: UserWorkspace): boolean {
|
||||
return workspace.parent_workspace_id == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a workspace (by id) is a fork or dev workspace. Forks and dev workspaces both set
|
||||
* `parent_workspace_id` (a dev workspace has no `wm-fork-` id prefix), but a `wm-fork-` workspace can
|
||||
* outlive its parent (the parent FK is `ON DELETE SET NULL`), so treat the prefix as fork-ness too —
|
||||
* otherwise an orphaned fork would lose its fork-only affordances (e.g. owner self-delete).
|
||||
*/
|
||||
export function workspaceIsFork(
|
||||
workspaceId: string | undefined,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): boolean {
|
||||
if (!workspaceId) return false
|
||||
if (workspaceId.startsWith('wm-fork-')) return true
|
||||
return allWorkspaces.find((w) => w.id === workspaceId)?.parent_workspace_id != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical dev workspace of a prod workspace, if any (at most one per prod). Used to redirect
|
||||
* edits from a locked prod workspace into its dev workspace. Disabled dev workspaces are excluded:
|
||||
* redirecting edits to one the user can't select would be a dead end.
|
||||
*/
|
||||
export function findCanonicalDevWorkspace(
|
||||
prodWorkspaceId: string | undefined,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): UserWorkspace | undefined {
|
||||
if (!prodWorkspaceId) return undefined
|
||||
return allWorkspaces.find(
|
||||
(w) => w.parent_workspace_id === prodWorkspaceId && w.is_dev_workspace && !w.disabled
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to find all descendants of a workspace
|
||||
*/
|
||||
export function findWorkspaceDescendants(
|
||||
workspaceId: string,
|
||||
workspaceId: string,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): UserWorkspace[] {
|
||||
const descendants: UserWorkspace[] = []
|
||||
@@ -126,4 +165,4 @@ export function findWorkspaceDescendants(
|
||||
|
||||
collectDescendants(workspaceId)
|
||||
return descendants
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
@@ -68,8 +68,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CiTestResults from '$lib/components/CiTestResults.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
@@ -281,10 +280,10 @@
|
||||
flow &&
|
||||
!$userStore?.operator &&
|
||||
!isCloudHosted() &&
|
||||
!isRuleActive('DisableWorkspaceForking')
|
||||
editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: 'Edit in fork',
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path),
|
||||
unifiedSize: 'md',
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === currentWorkspaceId))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
const isFork = $derived(!!parentWorkspaceId && currentWorkspaceId?.startsWith('wm-fork-'))
|
||||
// Fork/dev workspaces are identified by their parent link, not the `wm-fork-` id prefix.
|
||||
const isFork = $derived(!!parentWorkspaceId)
|
||||
|
||||
// Mode is seeded from the URL (?mode=draft|fork). `draft` is valid for any
|
||||
// workspace, so it resolves immediately. `fork` is only valid for an actual
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
import { useNestedRestartState } from '$lib/components/useNestedRestartState.svelte'
|
||||
import JobOtelTraces from '$lib/components/JobOtelTraces.svelte'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
|
||||
let jobUpdateLastFetch: Date | undefined = $state()
|
||||
@@ -753,13 +753,14 @@
|
||||
startIcon={{ icon: Pen }}>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
{#if !showEditButton && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')}
|
||||
{#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)}
|
||||
<Button
|
||||
href={buildForkEditUrl(isScript ? 'script' : 'flow', job?.script_path ?? '')}
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
size="sm"
|
||||
startIcon={{ icon: GitFork }}>Edit in fork</Button
|
||||
startIcon={{ icon: GitFork }}
|
||||
>{editInForkLabel($workspaceStore, $userWorkspaces)}</Button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
} from '$lib/utils'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
hubBaseUrlStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
|
||||
|
||||
@@ -89,8 +95,7 @@
|
||||
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
|
||||
import WacDiagram from '$lib/components/graph/WacDiagram.svelte'
|
||||
@@ -369,10 +374,10 @@
|
||||
script &&
|
||||
!$userStore?.operator &&
|
||||
!isCloudHosted() &&
|
||||
!isRuleActive('DisableWorkspaceForking')
|
||||
editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: 'Edit in fork',
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('script', script.path),
|
||||
unifiedSize: 'md',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
|
||||
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
|
||||
import DevWorkspaceSetting from '$lib/components/DevWorkspaceSetting.svelte'
|
||||
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
|
||||
@@ -348,6 +349,7 @@
|
||||
| 'general'
|
||||
| 'webhook'
|
||||
| 'deploy_to'
|
||||
| 'dev_workspace'
|
||||
| 'error_handler'
|
||||
| 'success_handler'
|
||||
| 'critical_alerts'
|
||||
@@ -1112,6 +1114,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The Dev workspace tab is only meaningful on a root workspace (to pair/manage a dev) or on a
|
||||
// dev workspace itself (to see its prod / detach). Hide it for ordinary forks — pairing isn't
|
||||
// available there and the backend would reject it.
|
||||
const currentWsForDevTab = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
const showDevWorkspaceTab = $derived(
|
||||
!currentWsForDevTab?.parent_workspace_id || (currentWsForDevTab?.is_dev_workspace ?? false)
|
||||
)
|
||||
|
||||
// Navigation groups for sidebar
|
||||
const navigationGroups = $derived([
|
||||
{
|
||||
@@ -1160,6 +1170,17 @@
|
||||
aiDescription: 'Deployment UI workspace settings',
|
||||
isEE: true
|
||||
},
|
||||
...(showDevWorkspaceTab
|
||||
? [
|
||||
{
|
||||
id: 'dev_workspace',
|
||||
label: 'Dev workspace',
|
||||
aiId: 'workspace-settings-dev-workspace',
|
||||
aiDescription:
|
||||
'Pair this workspace with a dev workspace (same code, different environment)'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'rulesets',
|
||||
label: 'Rulesets',
|
||||
@@ -1343,6 +1364,12 @@
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{:else if tab == 'dev_workspace'}
|
||||
<SettingsPageHeader
|
||||
title="Dev workspace"
|
||||
description="Pair this workspace with a dev workspace: the same code with a different environment. Edits are made in the dev workspace and promoted to prod."
|
||||
/>
|
||||
<DevWorkspaceSetting />
|
||||
{:else if tab == 'rulesets'}
|
||||
<SettingsPageHeader
|
||||
title="Workspace Protection Rulesets"
|
||||
|
||||
Reference in New Issue
Block a user