diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index cba37529f2..879661d9dd 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -77,6 +77,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { ).toEqual({ enabled: true, defaultMode: "isolated_workspace" }); }); }); + +describe("operator default isolated execution workspaces", () => { + const withDefault = ( + projectPolicy: Parameters< + typeof applyDefaultIsolatedExecutionWorkspacePolicy + >[0]["projectPolicy"], + hasProject = true, + defaultIsolatedWorkspacesEnabled = true, + ) => + applyDefaultIsolatedExecutionWorkspacePolicy({ + projectPolicy, + defaultIsolatedWorkspacesEnabled, + hasProject, + }); + + it("substitutes an isolated policy for a project that stores none", () => { + expect(withDefault(null)).toEqual({ + enabled: true, + defaultMode: "isolated_workspace", + }); + }); + + it("leaves everything alone while the operator default is off", () => { + expect(withDefault(null, true, false)).toBeNull(); + }); + + it("keeps a task that has no project on its existing behavior", () => { + // Isolation needs a repository to cut a worktree from. A project-less task + // (agent chat, for example) must not be pulled into worktree mode. + expect(withDefault(null, false)).toBeNull(); + }); + + it("never overrides a policy the project already stores", () => { + expect(withDefault({ enabled: true, defaultMode: "shared_workspace" })).toEqual({ + enabled: true, + defaultMode: "shared_workspace", + }); + // `enabled: false` is a tenant decision to stay on the shared checkout, + // not an absent policy to fill in. + expect(withDefault({ enabled: false })).toEqual({ enabled: false }); + }); + + it("resolves an unpolicied project's tasks to an isolated workspace", () => { + expect( + resolveExecutionWorkspaceMode({ + projectPolicy: withDefault(null), + issueSettings: null, + legacyUseProjectWorkspace: null, + }), + ).toBe("isolated_workspace"); + }); + + it("still lets an explicit issue setting win over the operator default", () => { + expect( + resolveExecutionWorkspaceMode({ + projectPolicy: withDefault(null), + issueSettings: { mode: "shared_workspace" }, + legacyUseProjectWorkspace: null, + }), + ).toBe("shared_workspace"); + }); + + it("keeps mode and strategy coherent for the substituted policy", () => { + // Substituting a policy (rather than moving the terminal fallback) is what + // makes `hasWorkspaceControl` true, so the default git_worktree strategy is + // supplied instead of leaving isolated mode on a project_primary strategy. + const projectPolicy = withDefault(null); + const mode = resolveExecutionWorkspaceMode({ + projectPolicy, + issueSettings: null, + legacyUseProjectWorkspace: null, + }); + const config = buildExecutionWorkspaceAdapterConfig({ + agentConfig: {}, + projectPolicy, + issueSettings: null, + mode, + legacyUseProjectWorkspace: null, + }); + expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree"); + }); + + it("does not strand a project-less task as an unrunnable worktree", () => { + const projectPolicy = withDefault(null, false); + const mode = resolveExecutionWorkspaceMode({ + projectPolicy, + issueSettings: null, + legacyUseProjectWorkspace: null, + }); + const config = buildExecutionWorkspaceAdapterConfig({ + agentConfig: {}, + projectPolicy, + issueSettings: null, + mode, + legacyUseProjectWorkspace: null, + }); + expect( + isUnrunnableWorktreeCombo({ + issue: { + projectId: null, + projectWorkspaceId: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + }, + resolvedMode: mode, + resolvedStrategy: resolveEffectiveWorkspaceStrategyType(mode, config), + }), + ).toBe(false); + }); +}); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 29872b83a5..38218a50e6 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -37,6 +37,7 @@ describe("instance settings service", () => { enableNativeRunner: false, enableManagedSandboxOnly: false, enableIsolatedWorkspaces: true, + enableIsolatedWorkspacesByDefault: false, enableStreamlinedLeftNavigation: true, enableStreamlinedUi: true, enableApps: true, diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 0205950fc1..a2d308a424 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -164,6 +164,42 @@ export function gateProjectExecutionWorkspacePolicy( return projectPolicy; } +/** + * Operator default: a project that stores no policy of its own runs its tasks + * in an isolated per-task worktree instead of the shared project checkout. + * + * This substitutes a policy rather than moving the terminal fallback in + * `resolveExecutionWorkspaceMode`, and the distinction is load-bearing: + * + * - A task with no project must keep its existing behavior. Isolation needs a + * repository to cut a worktree from, and `isUnrunnableWorktreeCombo` blocks + * an isolated + `git_worktree` task that has neither `projectId` nor + * `projectWorkspaceId`. Moving the terminal fallback would resolve isolated + * for project-less tasks (agent chat, for example) and strand them before + * dispatch. `hasProject` keeps them on the untouched path. + * - `buildExecutionWorkspaceAdapterConfig` only supplies the default + * `git_worktree` strategy when some layer actually asserts workspace + * control. A moved fallback would leave `hasWorkspaceControl` false and + * produce isolated mode carrying a `project_primary` strategy — a + * combination no caller expects. Substituting a real policy makes + * `projectHasPolicy` true, so mode and strategy stay coherent. + * + * A stored project policy always wins, including one that is explicitly + * disabled: `parseProjectExecutionWorkspacePolicy` returns `enabled: false` + * for a blob that never opted in, and that is a tenant decision to stay on the + * shared checkout, not an absent one to fill in. + */ +export function applyDefaultIsolatedExecutionWorkspacePolicy(input: { + projectPolicy: ProjectExecutionWorkspacePolicy | null; + defaultIsolatedWorkspacesEnabled: boolean; + hasProject: boolean; +}): ProjectExecutionWorkspacePolicy | null { + if (!input.defaultIsolatedWorkspacesEnabled) return input.projectPolicy; + if (!input.hasProject) return input.projectPolicy; + if (input.projectPolicy) return input.projectPolicy; + return { enabled: true, defaultMode: "isolated_workspace" }; +} + type ParseIssueExecutionWorkspaceSettingsOptions = { includeEnvironmentId?: boolean; }; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6c40967b85..e982ba9905 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -436,6 +436,7 @@ import { type HeartbeatRunScratch, } from "./run-scratch.js"; import { + applyDefaultIsolatedExecutionWorkspacePolicy, buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, @@ -20049,6 +20050,12 @@ export function heartbeatService( await instanceSettings.getExperimental(); const isolatedWorkspacesEnabled = experimentalInstanceSettings.enableIsolatedWorkspaces; + // Inert on its own: the operator default only reaches the resolver when + // isolated workspaces are enabled at all, so a stack that has one flag + // without the other keeps its current behavior. + const defaultIsolatedWorkspacesEnabled = + isolatedWorkspacesEnabled && + experimentalInstanceSettings.enableIsolatedWorkspacesByDefault; const parsedIssueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings( issueContext?.executionWorkspaceSettings, @@ -20193,10 +20200,17 @@ export function heartbeatService( projectContext?.executionWorkspacePolicy, ); const projectExecutionWorkspacePolicy = - gateProjectExecutionWorkspacePolicy( - parsedProjectExecutionWorkspacePolicy, - isolatedWorkspacesEnabled, - ); + applyDefaultIsolatedExecutionWorkspacePolicy({ + projectPolicy: gateProjectExecutionWorkspacePolicy( + parsedProjectExecutionWorkspacePolicy, + isolatedWorkspacesEnabled, + ), + defaultIsolatedWorkspacesEnabled, + // A resolved project row, not the issue's raw `projectId`: the + // substituted policy must only reach a task whose repository the + // worktree can actually be cut from. + hasProject: Boolean(projectContext), + }); const retainedTrust = await resolveAndRetainRunTrustPreset(db, { companyId: agent.companyId, agentId: agent.id, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 439b4f690a..b3d5749d86 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -227,6 +227,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableNativeRunner: parsed.data.enableNativeRunner ?? true, enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, + enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true, // Apps graduated from Experimental. Ignore historical off values while @@ -269,6 +270,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableNativeRunner: true, enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, + enableIsolatedWorkspacesByDefault: false, enableStreamlinedLeftNavigation: true, enableStreamlinedUi: true, enableApps: true, diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index cb94acbf50..dd69265d56 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -73,6 +73,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableNativeRunner: false, enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, + enableIsolatedWorkspacesByDefault: false, enableStreamlinedLeftNavigation: true, enableStreamlinedUi: true, enableApps: true, @@ -111,6 +112,12 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { const WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR = 'button[aria-label="Toggle worktree run execution setting"]'; +const ISOLATED_WORKSPACES_TOGGLE_SELECTOR = + 'button[aria-label="Toggle isolated workspaces experimental setting"]'; + +const ISOLATED_WORKSPACES_BY_DEFAULT_TOGGLE_SELECTOR = + 'button[aria-label="Toggle isolated workspaces by default experimental setting"]'; + function setWorktreeRuntimeMeta(enabled: boolean) { const name = "paperclip-worktree-enabled"; let meta = document.querySelector(`meta[name="${name}"]`); @@ -400,6 +407,82 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(toggle?.getAttribute("aria-checked")).toBe("true"); }); + it("hides the isolated-workspaces-by-default toggle while isolated workspaces are off", async () => { + await renderPage(); + + expect(container.querySelector(ISOLATED_WORKSPACES_TOGGLE_SELECTOR)).not.toBeNull(); + expect( + container.querySelector(ISOLATED_WORKSPACES_BY_DEFAULT_TOGGLE_SELECTOR), + ).toBeNull(); + expect(container.textContent).not.toContain("Use Isolated Workspaces By Default"); + }); + + it("keeps the dependent toggle hidden even when its stored flag is already on", async () => { + // The operator default is inert without isolated workspaces, so the server + // ignores a stored `true`. The control must not imply otherwise. + currentExperimentalSettings = { + ...currentExperimentalSettings, + enableIsolatedWorkspaces: false, + enableIsolatedWorkspacesByDefault: true, + }; + await renderPage(); + + expect( + container.querySelector(ISOLATED_WORKSPACES_BY_DEFAULT_TOGGLE_SELECTOR), + ).toBeNull(); + expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); + }); + + it("renders and patches the isolated-workspaces-by-default toggle on and off", async () => { + currentExperimentalSettings = { + ...currentExperimentalSettings, + enableIsolatedWorkspaces: true, + }; + await renderPage(); + + expect(container.textContent).toContain("Use Isolated Workspaces By Default"); + expect(container.textContent).toContain("per-task worktree"); + + const toggle = container.querySelector( + ISOLATED_WORKSPACES_BY_DEFAULT_TOGGLE_SELECTOR, + ); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enableIsolatedWorkspacesByDefault: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ + enableIsolatedWorkspacesByDefault: false, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + }); + + it("reflects a stored isolated-workspaces-by-default value as checked", async () => { + currentExperimentalSettings = { + ...currentExperimentalSettings, + enableIsolatedWorkspaces: true, + enableIsolatedWorkspacesByDefault: true, + }; + await renderPage(); + + const toggle = container.querySelector( + ISOLATED_WORKSPACES_BY_DEFAULT_TOGGLE_SELECTOR, + ); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + }); + it("hides the worktree run-execution toggle when not running in a worktree", async () => { setWorktreeRuntimeMeta(false); await renderPage(); @@ -720,6 +803,30 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => { }); }); + it("keeps a managed isolated-workspaces-by-default setting locked", async () => { + // The cloud overlay owns this key, so the tenant sees its value but cannot + // write it back and have the overlay immediately override the write. + await renderPage({ + ...defaultExperimentalSettings(), + enableIsolatedWorkspaces: true, + enableIsolatedWorkspacesByDefault: true, + managedKeys: { + enableIsolatedWorkspacesByDefault: { managed: true, managedBy: "paperclip-cloud" }, + }, + }); + + const toggle = container.querySelector( + ISOLATED_WORKSPACES_BY_DEFAULT_TOGGLE_SELECTOR, + ); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + expect(toggle?.disabled).toBe(true); + expect(container.textContent).toContain(MANAGED_BADGE_TEXT); + + await act(() => toggle?.click()); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); + }); + it("keeps a managed chat connectors setting locked", async () => { await renderPage({ ...defaultExperimentalSettings(), diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 0bf6596812..c661268154 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -204,6 +204,8 @@ export function InstanceExperimentalSettings() { const enableChatConnectors = experimentalQuery.data?.enableChatConnectors === true; const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; + const enableIsolatedWorkspacesByDefault = + experimentalQuery.data?.enableIsolatedWorkspacesByDefault === true; // Streamlined left navigation is now the standard sidebar (PAP-12472); the // experimental opt-out was retired, so it no longer surfaces a toggle here. const enableStreamlinedUi = experimentalQuery.data?.enableStreamlinedUi !== false; @@ -392,6 +394,21 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle isolated workspaces experimental setting" /> + {enableIsolatedWorkspaces && ( + + toggleMutation.mutate({ enableIsolatedWorkspacesByDefault: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableIsolatedWorkspacesByDefault" + managed={managedKeys.enableIsolatedWorkspacesByDefault} + ariaLabel="Toggle isolated workspaces by default experimental setting" + /> + )} +