diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte
index e6295d24cd..2537105c72 100644
--- a/frontend/src/lib/components/DevWorkspaceSetting.svelte
+++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte
@@ -10,7 +10,11 @@
import { base } from '$lib/base'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
- import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
+ import {
+ loadProtectionRules,
+ fetchProtectionRulesForWorkspace,
+ isRuleUnconditionallyActiveInRulesets
+ } from '$lib/workspaceProtectionRules.svelte'
import { GitFork, ExternalLink } from 'lucide-svelte'
import { resource } from 'runed'
@@ -55,6 +59,48 @@
let busy = $state(false)
let labelBusy = $state(false)
+ // If this workspace already blocks direct deploy / forking through an existing protection rule, keep
+ // the matching lock toggle on but locked: attaching only manages its own reserved dev-workspace rule,
+ // so turning it "off" here couldn't lift a separately-defined block. Fetched only while the attach form
+ // is on screen; a failed fetch falls back to the editable default-on toggle (real rules still enforce).
+ const rootProtectionRules = resource(
+ () => (!parentId && !pairedDev ? $workspaceStore : undefined),
+ async (ws, _prev, { signal }) => {
+ if (!ws) return undefined
+ const rules = await fetchProtectionRulesForWorkspace(ws)
+ // The generated client can't take an abort signal, so drop a superseded response here: a late
+ // result for a previously selected workspace must not overwrite the current one's rules.
+ if (signal.aborted) throw new DOMException('superseded', 'AbortError')
+ return { ws, rules }
+ }
+ )
+ // Only trust a result that belongs to the current workspace (guards the in-flight window and any
+ // out-of-order response); undefined means "not known yet" and is treated as locked below.
+ let rootRules = $derived.by(() => {
+ const current = rootProtectionRules.current
+ return current && current.ws === $workspaceStore ? current.rules : undefined
+ })
+ // Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a
+ // bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users'
+ // direct-deploy / forking access.
+ let alreadyBlocksDeploy = $derived(
+ isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment')
+ )
+ let alreadyBlocksForking = $derived(
+ isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking')
+ )
+ // Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as
+ // engaged during that window so the toggle is locked on and the effective value stays true:
+ // otherwise a user could turn a lock off and attach before an existing rule is detected, sending
+ // false and omitting the reserved rule — leaving prod unprotected if that rule is later removed.
+ let rulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined)
+ let deployLocked = $derived(alreadyBlocksDeploy || rulesUnknown)
+ let forkingLocked = $derived(alreadyBlocksForking || rulesUnknown)
+ // Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the
+ // toggle's raw state, keeping the request consistent with what the locked toggle shows.
+ let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy)
+ let effectiveLockProdForking = $derived(forkingLocked || lockProdForking)
+
// 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.
@@ -92,8 +138,8 @@
workspace: $workspaceStore,
requestBody: {
dev_workspace_id: selectedDevId,
- lock_prod_deploy: lockProdDeploy,
- lock_prod_forking: lockProdForking,
+ lock_prod_deploy: effectiveLockProdDeploy,
+ lock_prod_forking: effectiveLockProdForking,
dev_workspace_label: attachLabel
}
})
@@ -217,13 +263,44 @@
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
-
-
+ {#if deployLocked}
+
+
+ {#if alreadyBlocksDeploy}
+ Already enforced by an existing protection rule
+ {/if}
+
+ {:else}
+
+ {/if}
+ {#if forkingLocked}
+
+
+ {#if alreadyBlocksForking}
+ Already enforced by an existing protection rule
+ {/if}
+
+ {:else}
+
+ {/if}
-
-
+ {#if deployLocked}
+
+
+ {#if rootAlreadyBlocksDeploy}
+ Already enforced by an existing protection rule
+ {/if}
+
+ {:else}
+
+ {/if}
+ {#if forkingLocked}
+
+
+ {#if rootAlreadyBlocksForking}
+ Already enforced by an existing protection rule
+ {/if}
+
+ {:else}
+
+ {/if}
{/if}
diff --git a/frontend/src/lib/workspaceProtectionRules.svelte.ts b/frontend/src/lib/workspaceProtectionRules.svelte.ts
index 1774ed60e2..9bca16005f 100644
--- a/frontend/src/lib/workspaceProtectionRules.svelte.ts
+++ b/frontend/src/lib/workspaceProtectionRules.svelte.ts
@@ -182,6 +182,23 @@ export function isRuleActiveInRulesets(
return rulesets.some((ruleset) => ruleset.rules.includes(ruleKind))
}
+/**
+ * Whether a rule kind is enforced with no bypass users/groups in at least one ruleset, the only case
+ * that matches the empty-bypass reserved dev-workspace lock. A bypassable rule does not, since adding
+ * the unconditional lock would revoke those users' access; callers keep such a toggle editable.
+ */
+export function isRuleUnconditionallyActiveInRulesets(
+ rulesets: ProtectionRuleset[],
+ ruleKind: ProtectionRuleKind
+): boolean {
+ return rulesets.some(
+ (ruleset) =>
+ ruleset.rules.includes(ruleKind) &&
+ ruleset.bypass_users.length === 0 &&
+ ruleset.bypass_groups.length === 0
+ )
+}
+
/**
* Checks if user can bypass a rule kind in given rulesets (workspace-agnostic version)
* @param rulesets Array of protection rulesets to check