mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
feat(workspaces): add an operator default for isolated execution workspaces (#13444)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The execution workspace subsystem decides if a task run uses the
shared project checkout or an isolated per-task git worktree
> - The mode comes from the project policy, then the task settings. A
project that stores no policy always falls back to the shared checkout
> - An operator who wants every project to use isolated workspaces must
therefore edit each project one at a time, and must repeat this for each
new project
> - There is no instance-level control, so a fleet operator cannot set
this default at all
> - This pull request adds a managed experimental flag that moves the
default for projects that store no policy of their own
> - The benefit is that an operator sets the workspace default one time,
and every current and future project follows it
## Linked Issues or Issue Description
No public issue exists. The description follows the feature request
template.
**Subsystem affected**
Execution workspaces. The files are
`server/src/services/execution-workspace-policy.ts` and the run dispatch
path in `server/src/services/heartbeat.ts`.
**Problem or motivation**
`resolveExecutionWorkspaceMode` reads only the project policy, the task
settings, and a legacy field. Its last statement returns
`shared_workspace`. A project that stores no policy always gets the
shared project checkout.
An operator has no way to change this default for many projects at the
same time. The operator must edit each project, and must edit each new
project again later. Tasks in one project therefore share one checkout,
and they run one at a time when the environment driver makes the
scheduler serialize them.
**Proposed solution**
Add the managed experimental flag `enableIsolatedWorkspacesByDefault`.
When the flag is on, a project that stores no policy of its own resolves
as if it selected isolated workspaces. A project that stores a policy
keeps that policy.
The new helper substitutes a project policy. It does not move the last
statement of `resolveExecutionWorkspaceMode`. Two behaviors make this
necessary:
- A task that has no project must keep its current behavior. An isolated
workspace needs a repository to cut a worktree from.
`isUnrunnableWorktreeCombo` blocks an isolated task that has no
`projectId` and no `projectWorkspaceId`. A moved fallback would resolve
isolated for project-less tasks, such as agent chat, and stop them
before dispatch.
- The mode and the strategy must agree.
`buildExecutionWorkspaceAdapterConfig` supplies the default
`git_worktree` strategy only when one layer asserts workspace control. A
moved fallback would leave isolated mode with a `project_primary`
strategy.
**Alternatives considered**
- Change the last statement of `resolveExecutionWorkspaceMode` to
`isolated_workspace`. This is one line, but it changes the default for
every deployment. It is also not gated, so it would apply where isolated
workspaces are off.
- Write the policy to each project row with a script. This does not
cover new projects, and it does not cover new instances.
- Add an instance defaults section to the managed-config document. This
needs a new document key, new validation, and new delivery code. A
boolean flag reuses the delivery machinery that exists today.
**Roadmap alignment**
`ROADMAP.md` does not list execution workspace defaults. This change
adds an operator control to an existing capability. It does not add a
new capability.
**Additional context**
The flag is `tier: "managed"`. A cloud operator can therefore deliver it
with the managed-config machinery that exists today. No new delivery
code is needed.
## What Changed
- Add `enableIsolatedWorkspacesByDefault` to the feature catalog with
`tier: "managed"`. Both defaults are off.
- Add the flag to the experimental settings schema, the type, and both
branches of `normalizeExperimentalSettings`.
- Add `applyDefaultIsolatedExecutionWorkspacePolicy` to
`execution-workspace-policy.ts`. It substitutes `{ enabled: true,
defaultMode: "isolated_workspace" }` only when the flag is on, the task
has a project, and the project stores no policy.
- Apply the helper in the run dispatch path in `heartbeat.ts`, after the
existing `gateProjectExecutionWorkspacePolicy` call. The `hasProject`
argument reads the resolved project row, not the raw `projectId` of the
task.
- Gate the new flag behind `enableIsolatedWorkspaces` at the call site.
The new flag does nothing on its own.
- Add a toggle card to the instance experimental settings page. The card
shows only when isolated workspaces are on.
- Add eight tests for the new helper.
## Verification
Commands:
```
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/ui typecheck
cd server && ../node_modules/.bin/tsc --noEmit
```
The server typecheck script also builds a Rust binary. I ran `tsc`
directly because this machine has no `cargo`. The server package reports
no type errors.
Tests:
```
./node_modules/.bin/vitest run \
server/src/__tests__/execution-workspace-policy.test.ts \
server/src/__tests__/instance-settings-service.test.ts \
server/src/__tests__/instance-settings-cloud-defaults.test.ts \
server/src/__tests__/instance-settings-managed-overlay.test.ts \
server/src/__tests__/instance-settings-routes.test.ts \
server/src/__tests__/managed-config.test.ts \
server/src/__tests__/heartbeat-workspace-busy.test.ts \
server/src/__tests__/heartbeat-workspace-session.test.ts \
server/src/__tests__/heartbeat-workspace-ready-comment.test.ts \
server/src/__tests__/execution-workspaces-service.test.ts \
server/src/__tests__/issue-runtime-workspace-binding.test.ts \
server/src/__tests__/run-trust-preset.test.ts \
packages/shared/src/feature-catalog.test.ts \
packages/shared/src/settings-visibility.test.ts \
packages/shared/src/validators/instance.test.ts \
ui/src/pages/InstanceExperimentalSettings.test.tsx \
ui/src/components/Sidebar.test.tsx
```
All of these files pass. The new tests cover each of these cases:
- The helper substitutes an isolated policy for a project that stores
none.
- The helper changes nothing while the flag is off.
- The helper changes nothing for a task that has no project.
- The helper keeps a stored policy, including a policy with `enabled:
false`.
- The resolver returns `isolated_workspace` for an unpolicied project.
- An explicit task setting still wins over the operator default.
- The substituted policy produces the `git_worktree` strategy.
- A project-less task does not become an unrunnable worktree.
To confirm the behavior by hand:
1. Turn on Isolated Workspaces, then turn on Use Isolated Workspaces By
Default.
2. Open a project that has no execution workspace policy.
3. Start a task in that project.
4. The run gets its own worktree. Tasks in that project no longer wait
for each other.
## Risks
Low to medium. The details:
- The flag defaults to off, and it is inert unless
`enableIsolatedWorkspaces` is also on. An instance that does not turn on
both flags sees no change.
- A project that stores a policy keeps it. This includes a policy with
`enabled: false`, which the helper reads as a decision to stay on the
shared checkout.
- When an operator turns the flag on, the workspace configuration
fingerprint changes for projects that store no policy. Their next run
creates a new workspace. This is correct, because the mode did change,
but the first run after the change does more setup work.
- A task that is in flight when the flag changes resumes with a
different workspace path than the path its session remembers. An
operator should let current runs finish before turning the flag on.
- Isolated workspaces use more disk, because each task gets its own
worktree.
## Model Used
Claude Opus 5 (`claude-opus-5`) in Claude Code, with extended thinking
and tool use. The model read the repository, made the change, and ran
the typechecks and tests above.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee81cee76d
commit
b64469e403
10 files changed
+309
-4
No files matched your search
@@ -77,6 +77,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIsolatedWorkspacesByDefault: {
|
||||
title: "Isolated Workspaces By Default",
|
||||
description:
|
||||
"Treat a project that has no execution workspace policy of its own as if it selected isolated workspaces, so its tasks get a per-task worktree instead of sharing the project checkout. Requires Isolated Workspaces. A project that carries its own policy keeps it.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableStreamlinedLeftNavigation: {
|
||||
title: "Streamlined Left Navigation",
|
||||
description: "Use the streamlined main sidebar navigation layout.",
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface InstanceExperimentalSettings {
|
||||
*/
|
||||
enableManagedSandboxOnly: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
/**
|
||||
* Move the execution workspace default for a project that carries no policy
|
||||
* of its own from the shared project checkout to an isolated per-task
|
||||
* worktree. Inert unless `enableIsolatedWorkspaces` is also on, and never
|
||||
* overrides a project that stores its own policy.
|
||||
*/
|
||||
enableIsolatedWorkspacesByDefault: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
/**
|
||||
* Use the streamlined shell, navigation, and contextual-sidebar experience.
|
||||
|
||||
@@ -44,6 +44,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableNativeRunner: z.boolean().default(true),
|
||||
enableManagedSandboxOnly: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableIsolatedWorkspacesByDefault: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(true),
|
||||
enableStreamlinedUi: z.boolean().default(true),
|
||||
// Deprecated compatibility key. Apps is a standard product surface and is
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
applyDefaultIsolatedExecutionWorkspacePolicy,
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
ManagedSandboxUnavailableError,
|
||||
resolveEffectiveWorkspaceStrategyType,
|
||||
resolveExecutionWorkspaceEnvironmentId,
|
||||
resolvePinnedIssueWorkspaceStrategyType,
|
||||
resolveExecutionWorkspaceMode,
|
||||
@@ -532,3 +534,113 @@ describe("execution workspace policy helpers", () => {
|
||||
).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);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ describe("instance settings service", () => {
|
||||
enableNativeRunner: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIsolatedWorkspacesByDefault: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLMetaElement>(`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<HTMLButtonElement>(
|
||||
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<HTMLButtonElement>(
|
||||
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<HTMLButtonElement>(
|
||||
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(),
|
||||
|
||||
@@ -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 && (
|
||||
<ExperimentalToggleCard
|
||||
title="Use Isolated Workspaces By Default"
|
||||
description="Treat a project that has no execution workspace policy of its own as if it selected isolated workspaces, so its tasks get a per-task worktree instead of sharing the project checkout. A project that carries its own policy keeps it."
|
||||
checked={enableIsolatedWorkspacesByDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({ enableIsolatedWorkspacesByDefault: checked })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableIsolatedWorkspacesByDefault"
|
||||
managed={managedKeys.enableIsolatedWorkspacesByDefault}
|
||||
ariaLabel="Toggle isolated workspaces by default experimental setting"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Experimental File Viewer"
|
||||
description="Show task detail controls for browsing and previewing workspace files relative to a task."
|
||||
|
||||
Reference in new issue
Block a user