diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index a52fba4e..71ef6192 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -3,6 +3,7 @@ import { buildExecutionWorkspaceAdapterConfig, defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -142,6 +143,14 @@ describe("execution workspace policy helpers", () => { }); }); + it("maps persisted execution workspace modes back to issue settings", () => { + expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default"); + }); + it("disables project execution workspace policy when the instance flag is off", () => { expect( gateProjectExecutionWorkspacePolicy( diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 53487324..4f79beb3 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -132,6 +132,18 @@ export function defaultIssueExecutionWorkspaceSettingsForProject( }; } +export function issueExecutionWorkspaceModeForPersistedWorkspace( + mode: string | null | undefined, +): IssueExecutionWorkspaceSettings["mode"] { + if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") { + return mode; + } + if (mode === "adapter_managed" || mode === "cloud_sandbox") { + return "agent_default"; + } + return "shared_workspace"; +} + export function resolveExecutionWorkspaceMode(input: { projectPolicy: ProjectExecutionWorkspacePolicy | null; issueSettings: IssueExecutionWorkspaceSettings | null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0694efed..96a42f7e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -2098,11 +2099,29 @@ export function heartbeatService(db: Db) { cleanupReason: null, }); } - if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { - await issuesSvc.update(issueId, { - executionWorkspaceId: persistedExecutionWorkspace.id, - ...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}), - }); + if (issueId && persistedExecutionWorkspace) { + const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); + const shouldSwitchIssueToExistingWorkspace = + issueRef?.executionWorkspacePreference === "reuse_existing" || + executionWorkspaceMode === "isolated_workspace" || + executionWorkspaceMode === "operator_branch"; + const nextIssuePatch: Record = {}; + if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { + nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; + } + if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) { + nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId; + } + if (shouldSwitchIssueToExistingWorkspace) { + nextIssuePatch.executionWorkspacePreference = "reuse_existing"; + nextIssuePatch.executionWorkspaceSettings = { + ...(issueExecutionWorkspaceSettings ?? {}), + mode: nextIssueWorkspaceMode, + }; + } + if (Object.keys(nextIssuePatch).length > 0) { + await issuesSvc.update(issueId, nextIssuePatch); + } } if (persistedExecutionWorkspace) { context.executionWorkspaceId = persistedExecutionWorkspace.id; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 053e8e42..935a3b95 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -53,6 +53,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) { return "shared_workspace"; } +function shouldPresentExistingWorkspaceSelection(issue: Issue) { + const persistedMode = + issue.currentExecutionWorkspace?.mode + ?? issue.executionWorkspaceSettings?.mode + ?? issue.executionWorkspacePreference; + return Boolean( + issue.executionWorkspaceId && + (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), + ); +} + interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; @@ -268,10 +279,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp ? currentProject?.executionWorkspacePolicy ?? null : null; const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const currentExecutionWorkspaceSelection = - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(currentProject); const { data: reusableExecutionWorkspaces } = useQuery({ queryKey: queryKeys.executionWorkspaces.list(companyId!, { projectId: issue.projectId ?? undefined, @@ -298,9 +305,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp } return Array.from(seen.values()); }, [reusableExecutionWorkspaces]); - const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find( - (workspace) => workspace.id === issue.executionWorkspaceId, - ); + const selectedReusableExecutionWorkspace = + deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId) + ?? issue.currentExecutionWorkspace + ?? null; + const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue) + ? "reuse_existing" + : ( + issue.executionWorkspacePreference + ?? issue.executionWorkspaceSettings?.mode + ?? defaultExecutionWorkspaceModeForProject(currentProject) + ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; @@ -680,7 +695,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > {EXECUTION_WORKSPACE_OPTIONS.map((option) => ( ))}