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:
hugocasa
2026-06-30 19:01:07 +02:00
committed by GitHub
parent 83ed011e26
commit b4b0c6a93e
99 changed files with 2856 additions and 935 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -38,7 +38,8 @@
"google",
"ci_test",
"github",
"azure"
"azure",
"asset"
]
}
}
@@ -75,7 +76,8 @@
"google",
"ci_test",
"github",
"azure"
"azure",
"asset"
]
}
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "5190528997a879981a87420ddf3d28c978c8a5876f5c1ac1613391e86ffb550f"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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);
+1 -1
View File
@@ -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)
+741 -12
View File
@@ -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))
}
+134
View File
@@ -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
+50 -3
View File
@@ -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));
+28 -17
View File
@@ -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(
+48 -16
View File
@@ -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,
+2 -48
View File
@@ -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 &amp; 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}
+14 -5
View File
@@ -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
+1
View File
@@ -40,6 +40,7 @@ export interface UserWorkspace {
color?: string
operator_settings?: OperatorSettings
parent_workspace_id?: string | null
is_dev_workspace?: boolean
disabled: boolean
}
+78 -11
View File
@@ -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))}`
}
+52 -13
View File
@@ -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"