diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index b2985bd7a9..286f42f8f6 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -114,6 +114,16 @@ Daytona snapshot for future leases. while the rest of the page stays up. UI-visibility only; the secret provider-config and proposal APIs stay live for agents and integrations. +- `workspaces.isolation` hides project execution-workspace policy, task and + routine workspace selectors, pipeline workspace overrides, isolated re-issue + actions, and the execution-workspace Configuration tab (including direct + links). Workspace navigation, files, status, and runtime access stay available. + This key only controls UI visibility: it does not disable isolation, change + saved policies, or block APIs used by agents. New tasks and routine runs omit + hidden draft overrides so the server applies the existing defaults. Tasks + launched from a workspace or parent task keep that explicit context. Hide the two + experimental isolation toggles separately when the operator manages them. + Unknown keys are logged and ignored, so one list can be rolled across a fleet of mixed app versions, and retired keys (like `instance.heartbeats`, whose page was removed) can stay in an operator list without breaking older or diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 085fb0411a..8260662b3d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2680,6 +2680,7 @@ export { HIDEABLE_GENERAL_SECTIONS, HIDEABLE_INSTANCE_PAGES, HIDEABLE_SETTING_KEYS, + HIDEABLE_WORKSPACE_SECTIONS, SETTINGS_OPERATOR_MANAGED_ERROR_CODE, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, @@ -2695,6 +2696,7 @@ export { type HideableGeneralSection, type HideableInstancePage, type HideableSettingKey, + type HideableWorkspaceSection, type ParsedHiddenSettings, } from "./settings-visibility.js"; export { diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index 0184922a9d..0a657065fa 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -49,6 +49,11 @@ describe("hideable setting keys", () => { }); describe("parseHiddenSettingsList", () => { + it("accepts workspace controls independently of experimental flags", () => { + expect(parseHiddenSettingsList("workspaces.isolation")).toEqual({ hidden: ["workspaces.isolation"], unknown: [] }); + expect(hidesExperimentalSetting(new Set(["workspaces.isolation"]), "enableIsolatedWorkspaces")).toBe(false); + }); + it("returns nothing hidden for undefined or empty input", () => { expect(parseHiddenSettingsList(undefined)).toEqual({ hidden: [], unknown: [] }); expect(parseHiddenSettingsList(" , ,")).toEqual({ hidden: [], unknown: [] }); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 6729d4d062..3b1e104739 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -99,7 +99,12 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim return `instance.experimental.${key}`; } +/** Workspace policy editors and selectors. UI-only; execution and APIs stay active. */ +export const HIDEABLE_WORKSPACE_SECTIONS = ["workspaces.isolation"] as const; +export type HideableWorkspaceSection = (typeof HIDEABLE_WORKSPACE_SECTIONS)[number]; + export type HideableSettingKey = + | HideableWorkspaceSection | HideableInstancePage | HideableCompanyPage | HideableCompanySection @@ -108,6 +113,7 @@ export type HideableSettingKey = /** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */ export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ + ...HIDEABLE_WORKSPACE_SECTIONS, ...HIDEABLE_INSTANCE_PAGES, ...HIDEABLE_COMPANY_PAGES, ...HIDEABLE_COMPANY_SECTIONS, diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 6b3492230e..6d4ef3258f 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -438,12 +438,13 @@ function createExecutionState(overrides: Partial = {}): Iss }; } -function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps) { +function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps, hiddenSettings: string[] = []) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, }); + queryClient.setQueryData(queryKeys.health, { hiddenSettings }); const root = createRoot(container); act(() => { root.render( @@ -3451,6 +3452,21 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("hides workspace selection but keeps the bound workspace accessible", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockProjectsApi.list.mockResolvedValue([createProject({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } })]); + const onUpdate = vi.fn(); + const { root } = renderPropertiesWithQueryClient(container, { + issue: createIssue({ projectId: "project-1", executionWorkspaceId: "workspace-1", currentExecutionWorkspace: createExecutionWorkspace() }), + childIssues: [], onUpdate, inline: true, + }, ["workspaces.isolation"]); + await flush(); + expect(container.querySelector('[data-property-label="Execution"]')).toBeNull(); + expect(container.querySelector('a[href="/execution-workspaces/workspace-1"]')).not.toBeNull(); + expect(onUpdate).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + it("shows the workspace picker with no bound workspace", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); mockProjectsApi.list.mockResolvedValue([createProject({ diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index b8ba6701bb..d8480fb51c 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -13,6 +13,10 @@ vi.mock("@/lib/router", () => ({ ), })); +const visibility = vi.hoisted(() => ({ visible: true, loaded: true })); +vi.mock("@/hooks/useWorkspaceIsolationControls", () => ({ useWorkspaceIsolationControls: () => visibility })); +beforeEach(() => { visibility.visible = true; visibility.loaded = true; }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -458,6 +462,15 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => { expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).toBeNull(); }); + it("hides the isolated re-issue action under operator visibility policy", () => { + visibility.visible = false; + const onReissueIsolated = vi.fn(); + const node = render(); + expect(node.querySelector("[data-testid='recovery-action-reissue-trigger']")).toBeNull(); + expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull(); + expect(onReissueIsolated).not.toHaveBeenCalled(); + }); + it("offers the re-issue action and passes the live branch as the base ref", () => { const onReissueIsolated = vi.fn(); const node = render( diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 4f4736f263..8e7b7047be 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -1,3 +1,4 @@ +import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { useMemo, useState } from "react"; import type { @@ -993,6 +994,7 @@ export function IssueRecoveryActionCard({ variant = "full", className, }: IssueRecoveryActionCardProps) { + const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const liveness = useMemo(() => ({ scheduledRetry }), [scheduledRetry]); const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action, liveness); const tone = STATE_TONE[cardState]; @@ -1065,6 +1067,7 @@ export function IssueRecoveryActionCard({ }); const reissueBaseRef = divergence?.reissueBaseRef ?? null; const showReissueAction = + workspaceIsolationControlsVisible && onReissueIsolated !== undefined && cardState !== "resolved" && divergence !== null && @@ -1096,7 +1099,7 @@ export function IssueRecoveryActionCard({ divergence !== null && divergence.cleanliness === "dirty"; const repairDisabledReason = repairContention - ? `Held by ${contentionLabel(repairContention)} — re-issue on an isolated workspace instead.` + ? `Held by ${contentionLabel(repairContention)}${showReissueAction ? " — re-issue on an isolated workspace instead." : "."}` : null; // When contended, the re-issue is the recommended path, so it takes the primary emphasis and a // "Recommended" hint while the repair button is disabled. diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 3e744ebef6..7d88e8ddfa 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -35,6 +35,10 @@ vi.mock("@/lib/router", () => ({ ), })); +const visibility = vi.hoisted(() => ({ visible: true, loaded: true })); +vi.mock("@/hooks/useWorkspaceIsolationControls", () => ({ useWorkspaceIsolationControls: () => visibility })); +beforeEach(() => { visibility.visible = true; visibility.loaded = true; }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -146,6 +150,31 @@ describe("IssueWorkspaceCard", () => { container.remove(); }); + it("keeps workspace details and files while suppressing editing and draft writes", () => { + visibility.visible = false; + useQueryMock.mockImplementation((options: { queryKey: unknown[] }) => ({ + data: options.queryKey[0] === "instance" ? { enableIsolatedWorkspaces: true } : [], + })); + const root = createRoot(container); + const onUpdate = vi.fn(); + const onDraftChange = vi.fn(); + const onBrowseFiles = vi.fn(); + act(() => root.render()); + expect(container.querySelector("select")).toBeNull(); + expect(container.textContent).not.toContain("Save"); + expect(container.textContent).toContain("View workspace details"); + const browse = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Browse files")); + act(() => browse!.click()); + expect(onBrowseFiles).toHaveBeenCalledOnce(); + expect(onUpdate).not.toHaveBeenCalled(); + expect(onDraftChange).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + it("clears the legacy issue environment override when reusing a workspace", () => { const root = createRoot(container); const onUpdate = vi.fn(); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 8f82c5360f..372fec857c 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -1,3 +1,4 @@ +import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "@/lib/router"; import type { Issue, ExecutionWorkspace } from "@paperclipai/shared"; @@ -193,6 +194,7 @@ export function IssueWorkspaceCard({ onBrowseFiles, onOpenFileByPath, }: IssueWorkspaceCardProps) { + const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const { selectedCompanyId } = useCompany(); const companyId = issue.companyId ?? selectedCompanyId; const [editing, setEditing] = useState(initialEditing); @@ -236,7 +238,7 @@ export function IssueWorkspaceCard({ projectWorkspaceId: issue.projectWorkspaceId ?? undefined, reuseEligible: true, }), - enabled: Boolean(companyId) && Boolean(issue.projectId) && editing, + enabled: Boolean(companyId) && Boolean(issue.projectId) && editing && workspaceIsolationControlsVisible, }); const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? []; @@ -306,15 +308,15 @@ export function IssueWorkspaceCard({ ]); useEffect(() => { - if (!onDraftChange) return; + if (!onDraftChange || !workspaceIsolationControlsVisible) return; onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig, workspaceBranchName: draftWorkspaceBranchName, }); - }, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]); + }, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange, workspaceIsolationControlsVisible]); const handleSave = useCallback(() => { - if (!canSaveWorkspaceConfig) return; + if (!canSaveWorkspaceConfig || !workspaceIsolationControlsVisible) return; const update = buildWorkspaceDraftUpdate(); if (!update) return; onUpdate(update); @@ -322,6 +324,7 @@ export function IssueWorkspaceCard({ }, [ buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, + workspaceIsolationControlsVisible, onUpdate, ]); @@ -333,7 +336,7 @@ export function IssueWorkspaceCard({ if (!policyEnabled || !project) return null; - const showEditingControls = livePreview || editing; + const showEditingControls = workspaceIsolationControlsVisible && (livePreview || editing); return (
@@ -346,37 +349,39 @@ export function IssueWorkspaceCard({ : configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)} {workspace ? statusBadge(workspace.status) : statusBadge("idle")}
-
- {showEditingControls ? ( - <> + {workspaceIsolationControlsVisible && ( +
+ {showEditingControls ? ( + <> + + + + ) : ( - - - ) : ( - - )} -
+ )} +
+ )} {/* Read-only info */} @@ -448,7 +453,7 @@ export function IssueWorkspaceCard({ )} {/* Editing controls */} - {editing && ( + {editing && workspaceIsolationControlsVisible && (