diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 77da199bc44..c8093d7a21c 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -41,6 +41,15 @@ describe("execution workspace policy helpers", () => { }), ).toEqual({ mode: "shared_workspace" }); expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull(); + expect(defaultIssueExecutionWorkspaceSettingsForProject({ enabled: true })).toBeNull(); + }); + + it.each(["shared_workspace", "operator_branch"] as const)("keeps declared provider strategy for %s", mode => { + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, issueSettings: null, mode, legacyUseProjectWorkspace: null, + projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001", defaultMode: mode, + workspaceStrategy: { type: "git_worktree", baseRef: "release" } } }); + expect(config.workspaceStrategy).toEqual({ type: "git_worktree", baseRef: "release" }); + expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree"); }); it("prefers explicit issue mode over project policy and legacy overrides", () => { diff --git a/server/src/__tests__/project-workspace-provider.integration.test.ts b/server/src/__tests__/project-workspace-provider.integration.test.ts index 5353633269e..0d7cc1ad8f8 100644 --- a/server/src/__tests__/project-workspace-provider.integration.test.ts +++ b/server/src/__tests__/project-workspace-provider.integration.test.ts @@ -1,13 +1,17 @@ +import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { eq, sql } from "drizzle-orm"; -import { companies, createDb, executionWorkspaces, pluginCompanySettings, plugins, projects } from "@paperclipai/db"; +import { companies, createDb, executionWorkspaces, heartbeatRuns, issues, pluginCompanySettings, plugins, projects } from "@paperclipai/db"; import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { effectiveProjectWorkspacePolicy, listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "../services/project-workspace-provider.js"; import { preparePluginWorkspace } from "../services/plugin-workspaces.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; +import { issueService } from "../services/issues.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { buildExecutionWorkspaceAdapterConfig, parseIssueExecutionWorkspaceSettings, resolveEffectiveWorkspaceStrategyType, resolveExecutionWorkspaceMode } from "../services/execution-workspace-policy.js"; import { projectService } from "../services/projects.js"; import { projectRoutes } from "../routes/projects.js"; import { errorHandler } from "../middleware/index.js"; @@ -52,6 +56,45 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = expect(f.calls[0].operation).toMatchObject({ mode: "isolated_workspace", strategy: "git_worktree" }); }); + it.each(["create", "import"] as const)("%s leaves an unspecified task mode inherited from the effective provider", async method => { + const f = await fixture(); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true } }).where(eq(projects.id, f.input.projectId)); + let issueId: string; + if (method === "create") { + const created = await issueService(db).create(f.companyId, { title: "Use inherited storage", projectId: f.input.projectId, assigneeAgentId: f.input.agentId }); + issueId = created.id; + } else { + issueId = randomUUID(); + await issueService(db).importIssues(f.companyId, [{ id: issueId, ref: "inherited", title: "Import inherited storage", projectId: f.input.projectId, + projectWorkspaceId: null, description: null, assigneeAgentId: f.input.agentId, status: "todo", priority: "medium", billingCode: null, + assigneeAdapterOverrides: null, executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null }]); + } + const [task] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(task.executionWorkspaceSettings?.mode).toBeUndefined(); + const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true }, true); + const settings = parseIssueExecutionWorkspaceSettings(task.executionWorkspaceSettings); + const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: settings, legacyUseProjectWorkspace: null }); + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: policy, issueSettings: settings, mode, legacyUseProjectWorkspace: null }); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.input.agentId, + issueId, scopeKind: "issue", status: "running", runtimeMode: "legacy" }).returning(); + await preparePluginWorkspace(db, f.workers, { ...f.input, runId: run.id, issueId, mode, strategy: resolveEffectiveWorkspaceStrategyType(mode, config) }); + expect(f.calls[0].operation).toMatchObject({ mode: "isolated_workspace", strategy: "git_worktree" }); + }); + + it("pins and unpins the same effective owner without retiring its active workspace", async () => { + const f = await fixture(); + const workspace = await preparePluginWorkspace(db, f.workers, f.input); + const svc = projectService(db); + expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))!.workspaceProvider) + .toMatchObject({ source: "project", pluginId: f.pluginId }); + expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true } }))!.workspaceProvider) + .toMatchObject({ source: "default", pluginId: f.pluginId }); + expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace); + await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })) + .rejects.toThrow("Retire managed execution workspaces"); + }); + it("preserves explicit alternatives and the explicit Core/local choice", async () => { const f = await fixture(), other = await createPluginWorkspaceFixture(db, f.companyId); const providers = await listProjectWorkspaceProviders(db, f.companyId); diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index e6d7d3741f9..39ae803c488 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -345,7 +345,8 @@ export function resolveExecutionWorkspaceEnvironmentId(input: { export function defaultIssueExecutionWorkspaceSettingsForProject( projectPolicy: ProjectExecutionWorkspacePolicy | null, ): IssueExecutionWorkspaceSettings | null { - if (!projectPolicy?.enabled) return null; + // An unspecified mode must keep inheriting the effective provider default. + if (!projectPolicy?.enabled || projectPolicy.defaultMode === undefined) return null; return { mode: projectPolicy.defaultMode === "isolated_workspace" @@ -450,7 +451,8 @@ export function buildExecutionWorkspaceAdapterConfig(input: { const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false; if (hasWorkspaceControl) { - if (input.mode === "isolated_workspace") { + if (input.mode === "isolated_workspace" || + (projectHasPolicy && input.projectPolicy?.storageProviderPluginId && input.mode !== "agent_default")) { const projectStrategy = projectHasPolicy ? input.projectPolicy?.workspaceStrategy : undefined; const issueStrategy = input.issueSettings?.workspaceStrategy; // An issue that changes its branch still needs the project's setup hooks. @@ -459,7 +461,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: { ? { ...projectStrategy, ...issueStrategy } : issueStrategy ?? projectStrategy ?? parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ?? - ({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy); + ({ type: input.mode === "isolated_workspace" ? "git_worktree" : "project_primary" } satisfies ExecutionWorkspaceStrategy); if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) { delete strategy.branchTemplate; } diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index b9c75e11c81..a0c613daad3 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -96,11 +96,15 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", }; return { input, source, issue, run, envOrchestrator }; } - it.each([false, true])("prepares managed storage before acquisition (inherited default: %s) without touching a Core checkout", async inherited => { + it.each([ + { inherited: false, mode: "isolated_workspace" as const }, + { inherited: true, mode: "isolated_workspace" as const }, + { inherited: true, mode: "operator_branch" as const }, + ])("prepares managed storage before acquisition (inherited: $inherited, mode: $mode) without touching a Core checkout", async ({ inherited, mode: defaultMode }) => { const { input, source, issue } = await fixture(true); const [plugin] = await db.insert(plugins).values({ pluginKey: "test.managed-storage", packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"], manifestJson: { id: "test.managed-storage", apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"], - workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: "isolated_workspace" as const, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); const explicitPolicy = { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const, workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } }; if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: explicitPolicy }).where(eq(projects.id, issue.projectId!)); @@ -135,7 +139,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", await expect(access(source)).rejects.toThrow(); const [task] = await db.select().from(issues).where(eq(issues.id, issue.id)); const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!)); - expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" }); + expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, mode: defaultMode, strategyType: "git_worktree", branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" }); }); async function installDefault() { const [plugin] = await db.insert(plugins).values({ pluginKey: "test.default-storage", packageName: "test-storage", version: "1.0.0", status: "ready", diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index b23e32875d8..7f6795d2132 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -979,7 +979,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool if (!existingProject) return null; if (projectData.executionWorkspacePolicy !== undefined && projectData.executionWorkspacePolicy?.storageProviderPluginId !== existingProject.executionWorkspacePolicy?.storageProviderPluginId) { - await retireRemovedPluginWorkspaceDependents(db, id); + const providers = await listProjectWorkspaceProviders(db, existingProject.companyId); + const previousOwner = resolveProjectWorkspaceProvider(existingProject.executionWorkspacePolicy, providers, false).pluginId; + const nextOwner = resolveProjectWorkspaceProvider(projectData.executionWorkspacePolicy, providers, false).pluginId; + if (previousOwner !== nextOwner) await retireRemovedPluginWorkspaceDependents(db, id); } if (ids && ids.length > 0) { await assertGoalsBelongToCompany(db, existingProject.companyId, ids);