From 63b0752a9ab98f2d12d5fc76a74b4a448774300d Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 22 Oct 2026 16:43:41 -0700 Subject: [PATCH] Preserve inherited workspace settings through task preparation Keep unspecified task modes inherited during creation and import. Retain declared provider strategies for operator branches, and permit pinning or unpinning the same effective storage owner with active workspaces. Verified 58 targeted policy, database, and heartbeat regressions and the server TypeScript check. Full suites run in GitHub CI. Co-Authored-By: Paperclip --- .../execution-workspace-policy.test.ts | 9 ++++ ...ect-workspace-provider.integration.test.ts | 45 ++++++++++++++++++- .../services/execution-workspace-policy.ts | 8 ++-- .../heartbeat/workspace-preparation.test.ts | 10 +++-- server/src/services/projects.ts | 5 ++- 5 files changed, 69 insertions(+), 8 deletions(-) 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);