diff --git a/maps/agent-access-map/projects.md b/maps/agent-access-map/projects.md index 28884fc62d6..32bd0a3b74b 100644 --- a/maps/agent-access-map/projects.md +++ b/maps/agent-access-map/projects.md @@ -148,3 +148,9 @@ Evidence: `source-only` — reviewed at `9ef434fe79c57d976b8b03b14c5242db270b832 Verification: `not-run` — Verify allowed creation and reads as the actual operator/agent, then compare project and task access after adding/removing a person. Reject ordinary members’ audience changes, agent member/visibility mutations, foreign-company targets, and owner removal. After opening the project, inspect protected tasks and independent direct grants rather than inferring task publication from project discovery. Gaps: The dedicated create_project tool does not advertise a visibility field; explicit private selection uses REST or admitted call_api. Private-project creation through each client, inherited task audiences, and responsible-user combinations need separate live evidence. The map does not infer filesystem isolation from these app-record permissions. + +Project configuration responses also expose effective workspace storage through +`workspaceProvider`, separate from the stored execution policy. Existing project +read/update permissions apply. Omitted provider IDs inherit; null explicitly selects +Core/local. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts) +checks overrides and cross-company denial; live agent credential verification remains unqualified. diff --git a/maps/feature-map/projects.md b/maps/feature-map/projects.md index 86676190a17..b793d57d678 100644 --- a/maps/feature-map/projects.md +++ b/maps/feature-map/projects.md @@ -29,6 +29,8 @@ Open `/projects`, create a project, and enter its overview or Issues view. ### `repository-settings` Open `/projects/:projectId/configuration` and its repository/workspace settings. +Workspace storage shows the inherited installed default and allows an explicit +provider or Core/local choice. Conflicting defaults remain visible for repair. ### `project-access` @@ -46,6 +48,8 @@ Manual: Create a project, add a task from its view, and reload. Verify the task ### `repository-settings` +Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover inherited choices, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage. + Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows. Manual: Attach a small repository, save, and create a project task. Inspect the actual checkout/revision used by its run. Test an invalid repository and missing credentials without treating a saved URL as a working workspace. diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 03907ed52f5..5db1363d7d7 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -1315,10 +1315,43 @@ implement `onResourceLifecycle` and `onWorkspaceOperation`. Project lifecycle creates and retains the storage; workspace operations manage task checkouts in that storage. Execution remains a separate environment driver contract. -Set `project.executionWorkspacePolicy.storageProviderPluginId` to the installed -plugin's UUID. Core then reserves an execution-workspace row and binds the task -before it calls `onWorkspaceOperation`. Projects without this setting keep their -current local behavior. Existing managed workspaces retain their original owner. +Set `project.executionWorkspacePolicy.storageProviderPluginId` to an installed +plugin's UUID for an explicit selection, or `null` for explicit Core/local storage. +Omit the field to inherit the installed company-enabled default. A provider claims +that default by declaring, for example: + +```ts +workspaceProvider: { + modes: ["isolated_workspace", "operator_branch"], + strategies: ["git_worktree"], + defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" }, +} +``` + +The declared defaults must be supported by the provider. Core applies missing mode +and strategy settings to Git projects, including existing unconfigured projects, +without writing their policy. Explicit settings remain intact; incompatible +settings fail with a configuration error. Multiple enabled default claims require +an explicit project choice or disabling a conflicting provider. Worker health does +not change the selected default: unavailable providers fail without local fallback. +No company settings row means enabled, matching the normal plugin contract. + +New and imported tasks leave an unspecified project mode inherited. Provider-owned +workspaces retain explicit project and task strategies in shared and operator-branch +modes. If neither supplies a strategy, those modes use `project_primary`; an agent's +Git strategy remains a fallback only for isolated mode. + +Project responses keep the stored `executionWorkspacePolicy` and expose the +separate `workspaceProvider` configuration, including its source, effective policy, +provider choices and configuration error. The project configuration UI supports +inheritance, explicit plugins and Core/local storage. Conflicts remain readable so +operators can resolve them through this flow. + +Core reserves an execution-workspace row and binds the task before calling +`onWorkspaceOperation`. Source-free task directories do not prepare project storage. The resolved provider +choice, including Core/local, stays fixed through source resolution and preparation. +Existing workspaces, including local workspaces, retain their original owner. Changing defaults never migrates, resets or recreates them; +normal retirement and cleanup gates still apply on disable/uninstall. The hook receives a company, project, source revision, execution-workspace ID, lifecycle generation, deadline, and stable operation ID. Run and agent IDs are diff --git a/packages/plugins/sdk/tests/workspace-provider.test.ts b/packages/plugins/sdk/tests/workspace-provider.test.ts index 14b9f6af83e..f64aa7ece68 100644 --- a/packages/plugins/sdk/tests/workspace-provider.test.ts +++ b/packages/plugins/sdk/tests/workspace-provider.test.ts @@ -103,6 +103,13 @@ describe("workspace provider SDK", () => { resourceLifecycle: ["project"], }; expect(pluginManifestV1Schema.safeParse(manifest).success).toBe(true); + const withDefault = { ...manifest, workspaceProvider: { ...manifest.workspaceProvider, + defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } } }; + expect(pluginManifestV1Schema.parse(withDefault).workspaceProvider?.defaultForProjects).toEqual({ + mode: "isolated_workspace", strategy: "git_worktree", + }); + expect(pluginManifestV1Schema.safeParse({ ...withDefault, workspaceProvider: { ...withDefault.workspaceProvider, + defaultForProjects: { mode: "shared_workspace", strategy: "git_worktree" } } }).success).toBe(false); expect( pluginManifestV1Schema.safeParse({ ...manifest, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2b204b3cb88..f9f6db272b6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -933,6 +933,7 @@ export type { ProjectManagedByPlugin, ProjectVisibility, ProjectWorkspace, + ProjectWorkspaceProviderConfiguration, CompanySearchCountType, CompanySearchExtractIssueResult, CompanySearchExtractKind, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d654b94f5df..c89aa62b1e3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -367,6 +367,7 @@ export type { ProjectManagedByPlugin, ProjectVisibility, ProjectWorkspace, + ProjectWorkspaceProviderConfiguration, } from "./project.js"; export type { CompanySearchCountType, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index ef4d7be5550..3f121a74f45 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -698,6 +698,10 @@ export interface PaperclipPluginManifestV1 { workspaceProvider?: { modes: Array<"shared_workspace" | "isolated_workspace" | "operator_branch">; strategies: Array<"project_primary" | "git_worktree">; + defaultForProjects?: { + mode: "shared_workspace" | "isolated_workspace" | "operator_branch"; + strategy: "project_primary" | "git_worktree"; + }; }; /** Durable, acknowledged resource events delivered to onResourceLifecycle. Requires events.subscribe. */ resourceLifecycle?: Array<"agent" | "project" | "agent_runtime">; diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 17e3d8ef1bc..7cc1fb202d1 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -101,6 +101,15 @@ export interface ProjectDiscoveryPage { nextCursor: string | null; } +export interface ProjectWorkspaceProviderConfiguration { + source: "project" | "default" | "core"; + pluginId: string | null; + displayName: string; + providers: Array<{ id: string; displayName: string; isDefault: boolean }>; + effectivePolicy: ProjectExecutionWorkspacePolicy | null; + error: string | null; +} + export interface Project { id: string; companyId: string; @@ -126,6 +135,7 @@ export interface Project { pauseReason: PauseReason | null; pausedAt: Date | null; executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; + workspaceProvider?: ProjectWorkspaceProviderConfiguration; codebase: ProjectCodebase; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 88d81fec115..00ab237577d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -171,6 +171,7 @@ export interface ExecutionWorkspaceCloseReadiness { export interface ProjectExecutionWorkspacePolicy { enabled: boolean; + storageProviderPluginId?: string | null; sharedWorkspaceConcurrency?: SharedWorkspaceConcurrency; defaultMode?: ProjectExecutionWorkspaceDefaultMode; allowIssueOverride?: boolean; diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 6f011359783..7e333572d83 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -793,6 +793,10 @@ export const pluginManifestV1Schema = z.object({ workspaceProvider: z.object({ modes: z.array(z.enum(["shared_workspace", "isolated_workspace", "operator_branch"])).min(1).max(3), strategies: z.array(z.enum(["project_primary", "git_worktree"])).min(1).max(2), + defaultForProjects: z.object({ + mode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch"]), + strategy: z.enum(["project_primary", "git_worktree"]), + }).strict().optional(), }).strict().optional(), resourceLifecycle: z.array(z.enum(["agent", "project", "agent_runtime"])).min(1).max(3).optional(), aiConnectionRouter: z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(500) }).strict().optional(), @@ -826,6 +830,10 @@ export const pluginManifestV1Schema = z.object({ if (manifest.workspaceProvider && (!manifest.capabilities.includes("workspaces.manage") || !manifest.resourceLifecycle?.includes("project"))) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceProvider"], message: "Workspace providers require workspaces.manage and project resource lifecycle" }); } + const defaults = manifest.workspaceProvider?.defaultForProjects; + if (defaults && (!manifest.workspaceProvider!.modes.includes(defaults.mode) || !manifest.workspaceProvider!.strategies.includes(defaults.strategy))) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceProvider", "defaultForProjects"], message: "Project defaults must use a declared workspace mode and strategy" }); + } if (manifest.resourceLifecycle && !manifest.capabilities.includes("events.subscribe")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Resource lifecycle delivery requires events.subscribe", path: ["capabilities"] }); } diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 77da199bc44..e44ea327f3a 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -41,6 +41,23 @@ 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.each(["shared_workspace", "operator_branch"] as const)("keeps the primary-source fallback for %s providers without a project strategy", mode => { + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: { workspaceStrategy: { type: "git_worktree", baseRef: "agent-branch" } }, + issueSettings: null, mode, legacyUseProjectWorkspace: null, + projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001" } }); + expect(config.workspaceStrategy).toEqual({ type: "project_primary" }); + expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("project_primary"); }); 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 new file mode 100644 index 00000000000..0d7cc1ad8f8 --- /dev/null +++ b/server/src/__tests__/project-workspace-provider.integration.test.ts @@ -0,0 +1,207 @@ +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, 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"; + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("installed project workspace defaults", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("project-provider-defaults"); + db = createDb(database.connectionString); + }, 120_000); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + beforeEach(async () => { await db.execute(sql`truncate companies, plugins cascade`); }); + + async function fixture() { + const f = await createPluginWorkspaceFixture(db); + const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId)); + await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: { + ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" }, + } } }).where(eq(plugins.id, f.pluginId)); + await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId)); + return f; + } + + it("resolves new and existing unconfigured projects without persisting the inherited choice", async () => { + const f = await fixture(); + const svc = projectService(db); + const created = await svc.create(f.companyId, { name: "New project" }); + for (const projectId of [created!.id, f.input.projectId]) { + const project = await svc.getById(projectId); + expect(project!.workspaceProvider).toMatchObject({ source: "default", pluginId: f.pluginId }); + expect(project!.executionWorkspacePolicy).toBeNull(); + } + expect((await svc.getById(f.input.projectId))!.workspaceProvider!.effectivePolicy).toMatchObject({ + enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree" }, storageProviderPluginId: f.pluginId, + }); + expect(await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId: f.companyId, + projectId: f.input.projectId, sourceWorkspaceId: f.input.projectWorkspaceId, executionWorkspacePolicy: null })).toBe("git_worktree"); + const prepared = await preparePluginWorkspace(db, f.workers, f.input); + expect(prepared).toMatchObject({ providerType: "plugin" }); + 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); + expect(resolveProjectWorkspaceProvider({ enabled: true, storageProviderPluginId: other.pluginId }, providers, true)) + .toMatchObject({ source: "project", pluginId: other.pluginId }); + const local = { enabled: true, storageProviderPluginId: null }; + await projectService(db).update(f.input.projectId, { executionWorkspacePolicy: local }); + expect((await projectService(db).getById(f.input.projectId))!.executionWorkspacePolicy).toEqual(local); + expect(await preparePluginWorkspace(db, f.workers, f.input)).toBeNull(); + expect(f.calls).toHaveLength(0); + }); + + it.each(["disabled", "uninstalled"] as const)("does not inherit an instance %s provider", async status => { + const f = await fixture(); + await db.update(plugins).set({ status }).where(eq(plugins.id, f.pluginId)); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toBeNull(); + }); + + it("scopes company enablement and does not borrow another company's override", async () => { + const f = await fixture(); + const [other] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTH" }).returning(); + await db.insert(pluginCompanySettings).values({ companyId: f.companyId, pluginId: f.pluginId, enabled: false }); + await db.insert(pluginCompanySettings).values({ companyId: other.id, pluginId: f.pluginId, enabled: true }); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toBeNull(); + expect(await effectiveProjectWorkspacePolicy(db, other.id, null, true)).toMatchObject({ storageProviderPluginId: f.pluginId }); + await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true)) + .rejects.toThrow("unavailable"); + }); + + it.each(["error", "installed", "upgrade_pending", "ready"] as const)("retains the selected %s provider when its worker is unavailable", async status => { + const f = await fixture(); + await db.update(plugins).set({ status }).where(eq(plugins.id, f.pluginId)); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toMatchObject({ storageProviderPluginId: f.pluginId }); + await expect(preparePluginWorkspace(db, undefined, f.input)).rejects.toThrow("unavailable"); + expect(await db.select().from(executionWorkspaces)).toHaveLength(0); + }); + + it("never falls back when a selected default is disabled during preparation", async () => { + const f = await fixture(); + const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, null, true); + await db.insert(pluginCompanySettings).values({ companyId: f.companyId, pluginId: f.pluginId, enabled: false }); + await expect(preparePluginWorkspace(db, f.workers, { ...f.input, selectedProviderPluginId: policy!.storageProviderPluginId })) + .rejects.toThrow("unavailable"); + expect(await db.select().from(executionWorkspaces)).toHaveLength(0); + }); + + it("reports conflicting declarations and still lets configuration select an alternative", async () => { + const f = await fixture(); + const other = await createPluginWorkspaceFixture(db, f.companyId); + const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId)); + await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId)); + await expect(preparePluginWorkspace(db, f.workers, f.input)).rejects.toThrow("Multiple workspace storage plugins"); + const project = await projectService(db).getById(f.input.projectId); + expect(project!.workspaceProvider!.error).toContain("Select a storage provider in project configuration"); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true)) + .toMatchObject({ storageProviderPluginId: f.pluginId }); + }); + + it.each([ + { enabled: false }, + { enabled: true, defaultMode: "shared_workspace" }, + { enabled: true, workspaceStrategy: { type: "project_primary" } }, + ])("rejects incompatible explicit settings: %j", async policy => { + const f = await fixture(); + await expect(effectiveProjectWorkspacePolicy(db, f.companyId, policy, true)).rejects.toThrow("explicit project workspace settings"); + }); + + it("preserves compatible explicit fields while supplying only missing defaults", async () => { + const f = await fixture(); + const policy = { enabled: true, defaultMode: "operator_branch", workspaceStrategy: { type: "git_worktree", baseRef: "release", branchTemplate: "kept" } }; + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, policy, true)).toEqual({ ...policy, storageProviderPluginId: f.pluginId }); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, false)).toEqual({ enabled: true, storageProviderPluginId: f.pluginId }); + }); + + it("retains both plugin and local workspace ownership when defaults change", async () => { + const f = await fixture(); + const workspace = await preparePluginWorkspace(db, f.workers, f.input); + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }).where(eq(projects.id, f.input.projectId)); + expect(await preparePluginWorkspace(db, f.workers, { ...f.input, existingWorkspaceId: workspace!.id })).toMatchObject({ id: workspace!.id }); + await db.update(plugins).set({ status: "error" }).where(eq(plugins.id, f.pluginId)); + await expect(preparePluginWorkspace(db, f.workers, { ...f.input, existingWorkspaceId: workspace!.id })).rejects.toThrow("unavailable"); + const [local] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.input.projectId, + projectWorkspaceId: f.input.projectWorkspaceId, mode: "shared_workspace", strategyType: "project_primary", name: "Retained local", providerType: "local_fs", cwd: "/fixture/kept" }).returning(); + await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId)); + expect(await preparePluginWorkspace(db, f.workers, { ...f.input, existingWorkspaceId: local.id })).toBeNull(); + expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, local.id)))[0]).toEqual(local); + }); + + it("exposes inherited selection through the normal API and round-trips overrides with company access checks", async () => { + const f = await fixture(); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", isInstanceAdmin: true }; next(); }); + app.use("/api", projectRoutes(db)); + app.use(errorHandler); + const url = `/api/projects/${f.input.projectId}`; + expect((await request(app).get(url)).body.workspaceProvider).toMatchObject({ source: "default", pluginId: f.pluginId }); + const patch = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }); + expect(patch.status).toBe(200); + expect(patch.body.workspaceProvider).toMatchObject({ source: "project", pluginId: null }); + const inherit = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true } }); + expect(inherit.status).toBe(200); + expect(inherit.body.executionWorkspacePolicy).not.toHaveProperty("storageProviderPluginId"); + expect(inherit.body.workspaceProvider).toMatchObject({ source: "default", pluginId: f.pluginId }); + const denied = express(); + denied.use((req, _res, next) => { req.actor = { type: "agent", agentId: f.input.agentId, companyId: "00000000-0000-4000-8000-000000000099" }; next(); }); + denied.use("/api", projectRoutes(db)); denied.use(errorHandler); + expect((await request(denied).get(url)).status).toBe(404); + }); +}); diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 37fc7a7ff88..f25ee1451f0 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -130,7 +130,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu })(); return { enabled, - ...(typeof parsed.storageProviderPluginId === "string" ? { storageProviderPluginId: parsed.storageProviderPluginId } : {}), + ...(typeof parsed.storageProviderPluginId === "string" || parsed.storageProviderPluginId === null ? { storageProviderPluginId: parsed.storageProviderPluginId } : {}), ...(sharedWorkspaceConcurrency ? { sharedWorkspaceConcurrency } : {}), ...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}), ...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}), @@ -345,7 +345,7 @@ export function resolveExecutionWorkspaceEnvironmentId(input: { export function defaultIssueExecutionWorkspaceSettingsForProject( projectPolicy: ProjectExecutionWorkspacePolicy | null, ): IssueExecutionWorkspaceSettings | null { - if (!projectPolicy?.enabled) return null; + if (!projectPolicy?.enabled || projectPolicy.defaultMode === undefined) return null; return { mode: projectPolicy.defaultMode === "isolated_workspace" @@ -450,7 +450,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. @@ -458,8 +459,8 @@ export function buildExecutionWorkspaceAdapterConfig(input: { const strategy = issueStrategy && projectStrategy?.type === issueStrategy.type ? { ...projectStrategy, ...issueStrategy } : issueStrategy ?? projectStrategy ?? - parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ?? - ({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy); + (input.mode === "isolated_workspace" ? parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) : undefined) ?? + ({ 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/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 13830397cee..1065fcc1362 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,3 +1,4 @@ +import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js"; import { inspectPluginWorkspace, removePluginWorkspace, restorePluginWorkspace, type WorkspaceWorkers } from "./plugin-workspaces.js"; import { acquireExecutionWorkspaceLifecycleLock, withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifecycle-lock.js"; import { measureRunTransaction } from "./execution-transaction-timing.js"; @@ -1406,6 +1407,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic companyId: string; issueId?: string | null; assigneeAgentId?: string | null; assigneeAdapterOverrides?: unknown; executionWorkspaceSettings?: unknown; executionWorkspacePolicy: unknown; typedSelection?: boolean; projectId?: string | null; executionPolicy?: unknown; + sourceWorkspaceId?: string | null; inheritProviderDefaults?: boolean; }, reader: Db | DbTransaction = db) { const [task] = input.issueId ? await reader.select().from(issues).where(and( eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), @@ -1415,12 +1417,21 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(and(eq(agents.id, assigneeAgentId), eq(agents.companyId, input.companyId))) : []; const overrides = assigneeAgentId ? parseIssueAssigneeAdapterOverrides(task ? task.assigneeAdapterOverrides : input.assigneeAdapterOverrides) : null; const experimental = await instanceSettingsService(db).getExperimental(); + const sourceId = input.sourceWorkspaceId ?? task?.projectWorkspaceId; + const sourceProjectId = input.projectId ?? task?.projectId; + const [source] = sourceId || sourceProjectId ? await reader.select({ repoUrl: projectWorkspaces.repoUrl }).from(projectWorkspaces).where(and( + eq(projectWorkspaces.companyId, input.companyId), + sourceId ? eq(projectWorkspaces.id, sourceId) : eq(projectWorkspaces.projectId, sourceProjectId!), + )).orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)).limit(1) : []; + const effectivePolicy = input.inheritProviderDefaults === false + ? parseProjectExecutionWorkspacePolicy(input.executionWorkspacePolicy) + : await effectiveProjectWorkspacePolicy(reader, input.companyId, input.executionWorkspacePolicy, Boolean(source?.repoUrl)); const projectPolicy = applyDefaultIsolatedExecutionWorkspacePolicy({ - projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(input.executionWorkspacePolicy), experimental.enableIsolatedWorkspaces), + projectPolicy: gateProjectExecutionWorkspacePolicy(effectivePolicy, experimental.enableIsolatedWorkspaces || Boolean(effectivePolicy?.storageProviderPluginId)), defaultIsolatedWorkspacesEnabled: experimental.enableIsolatedWorkspaces && experimental.enableIsolatedWorkspacesByDefault, hasProjectWorkspace: true, }); - const settingsEnabled = experimental.enableIsolatedWorkspaces || input.typedSelection || task?.workspaceSelection || task?.executionWorkspaceId; + const settingsEnabled = experimental.enableIsolatedWorkspaces || Boolean(effectivePolicy?.storageProviderPluginId) || input.typedSelection || task?.workspaceSelection || task?.executionWorkspaceId; const issueSettings = settingsEnabled ? parseIssueExecutionWorkspaceSettings(input.executionWorkspaceSettings) : null; const projectId = task ? task.projectId : input.projectId; const [organization] = projectId ? await reader.select({ companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) @@ -1477,7 +1488,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic executionWorkspaceSettings = { ...parseObject(task.settings), mode }; } await resolveSourceWorkspaceStrategy({ ...input, typedSelection: true, - executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy }, reader); + executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy, sourceWorkspaceId: source.id }, reader); await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId); return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null, executionWorkspaceSettings }; @@ -1567,7 +1578,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.id, patch.projectWorkspaceId!), )); const { strategy, config } = await resolveSourceWorkspaceConfiguration({ ...input, typedSelection: true, - executionWorkspacePolicy: source?.policy, executionWorkspaceSettings: patch.executionWorkspaceSettings }, tx); + executionWorkspacePolicy: source?.policy, executionWorkspaceSettings: patch.executionWorkspaceSettings, sourceWorkspaceId: patch.projectWorkspaceId }, tx); if (strategy === "git_worktree") { const workspaceStrategy = { ...parseObject(config.workspaceStrategy), type: "git_worktree", existingBranch: null, branchTemplate: "retry-{{issue.id}}" }; @@ -1636,7 +1647,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic if (!sourceProject) throw notFound("Workspace source is unavailable or inaccessible"); await resolveSourceWorkspaceStrategy({ companyId: task.companyId, issueId: task.id, executionWorkspaceSettings: task.executionWorkspaceSettings, - executionWorkspacePolicy: sourceProject.executionWorkspacePolicy }, tx); + executionWorkspacePolicy: sourceProject.executionWorkspacePolicy, sourceWorkspaceId: sourceId, inheritProviderDefaults: !workspace }, tx); } async function prepareTaskWorkspaceUpdate(input: { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0d2d33e61d8..81672288f4e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -508,6 +508,7 @@ import { import { resolveAndRetainRunTrustPreset } from "./run-trust-preset.js"; +import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js"; @@ -2208,19 +2209,6 @@ export function heartbeatService( const [taskWorkspaceIntentRow] = issueId && !externalProvider ? await db.select({ intent: issues.workspaceSelection }).from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) : []; const freshWorkspace = await readFreshWorkspaceReadiness(db, run); - const runtimeWorkspaceSelectionEnabled = !externalProvider && (freshWorkspace?.mode === "fresh_isolated_workspace" || taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: isolatedWorkspacesEnabled, hasTypedSelection: Boolean(taskWorkspaceIntentRow?.intent), hasBinding: Boolean(issueContext?.executionWorkspaceId) })); - const parsedIssueExecutionWorkspaceSettings = - parseIssueExecutionWorkspaceSettings( - issueContext?.executionWorkspaceSettings, - ); - const issueExecutionWorkspaceSettings = runtimeWorkspaceSelectionEnabled - ? parsedIssueExecutionWorkspaceSettings - : null; - const environmentExecutionWorkspaceSettings = - selectEnvironmentExecutionWorkspaceSettings( - parsedIssueExecutionWorkspaceSettings, - runtimeWorkspaceSelectionEnabled, - ); const contextProjectId = readNonEmptyString(context.projectId); const boundSourceWorkspace = !externalProvider && issueContext?.executionWorkspaceId ? await executionWorkspacesSvc.getStoredById(agent.companyId, issueContext.executionWorkspaceId) : null; @@ -2242,6 +2230,9 @@ export function heartbeatService( or nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null)`, )), ).mapWith(Boolean), + hasGitSource: exists(db.select({ id: projectWorkspaces.id }).from(projectWorkspaces) + .where(and(eq(projectWorkspaces.projectId, projects.id), eq(projectWorkspaces.companyId, agent.companyId), + sql`nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null`))).mapWith(Boolean), env: projects.env, updatedAt: projects.updatedAt, }) @@ -2377,21 +2368,33 @@ export function heartbeatService( !boundSourceWorkspace && !taskWorkspaceIntentRow?.intent && !issueContext?.projectWorkspaceId ? taskProjectContext : null ); - const parsedProjectExecutionWorkspacePolicy = - parseProjectExecutionWorkspacePolicy( - workspacePolicyContext?.executionWorkspacePolicy, - ); + const parsedProjectExecutionWorkspacePolicy = workspacePolicyContext && !boundSourceWorkspace + ? await effectiveProjectWorkspacePolicy(db, agent.companyId, workspacePolicyContext.executionWorkspacePolicy, workspacePolicyContext.hasGitSource) + : parseProjectExecutionWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy); const projectExecutionWorkspacePolicy = applyDefaultIsolatedExecutionWorkspacePolicy({ projectPolicy: gateProjectExecutionWorkspacePolicy( parsedProjectExecutionWorkspacePolicy, - isolatedWorkspacesEnabled, + isolatedWorkspacesEnabled || Boolean(parsedProjectExecutionWorkspacePolicy?.storageProviderPluginId), ), defaultIsolatedWorkspacesEnabled, // Projects without workspace configuration get a plain managed // directory. The operator default cannot turn it into a worktree. hasProjectWorkspace: workspaceProjectContext?.hasWorkspace ?? false, }); + const runtimeWorkspaceSelectionEnabled = !externalProvider && (freshWorkspace?.mode === "fresh_isolated_workspace" || taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: isolatedWorkspacesEnabled || Boolean(projectExecutionWorkspacePolicy?.storageProviderPluginId), hasTypedSelection: Boolean(taskWorkspaceIntentRow?.intent), hasBinding: Boolean(issueContext?.executionWorkspaceId) })); + const parsedIssueExecutionWorkspaceSettings = + parseIssueExecutionWorkspaceSettings( + issueContext?.executionWorkspaceSettings, + ); + const issueExecutionWorkspaceSettings = runtimeWorkspaceSelectionEnabled + ? parsedIssueExecutionWorkspaceSettings + : null; + const environmentExecutionWorkspaceSettings = + selectEnvironmentExecutionWorkspaceSettings( + parsedIssueExecutionWorkspaceSettings, + runtimeWorkspaceSelectionEnabled, + ); const retainedTrust = await resolveAndRetainRunTrustPreset(db, { companyId: agent.companyId, agentId: agent.id, diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index b6fcedaab42..cc4d2e16dc7 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -25,7 +25,8 @@ import { isolatedRemoteSourcePath } from "../isolated-remote-workspace-source.js import { cleanupExecutionWorkspaceArtifacts } from "../workspace-runtime.js"; import { buildProjectMentionHref } from "@paperclipai/shared"; import { trace } from "@opentelemetry/api"; -import { buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js"; +import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js"; +import { resolveExecutionWorkspaceMode, buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js"; import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js"; import { createStorageService } from "../../storage/service.js"; import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace"; @@ -53,7 +54,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", }); beforeEach(async () => { vi.restoreAllMocks(); - await db.execute(sql`truncate table companies restart identity cascade`); + await db.execute(sql`truncate table companies, plugins restart identity cascade`); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); }); @@ -95,12 +96,26 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", }; return { input, source, issue, run, envOrchestrator }; } - it("prepares managed storage before acquisition without touching a Core checkout", async () => { + 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"] }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); - await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: plugin.id } }).where(eq(projects.id, issue.projectId!)); + 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!)); + { + const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, inherited ? null : explicitPolicy, true); + const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: null, legacyUseProjectWorkspace: null }); + input.policy.projectExecutionWorkspacePolicy = policy; + input.policy.requestedExecutionWorkspaceMode = mode; + input.policy.effectiveExecutionWorkspaceMode = mode; + input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: input.config.mergedConfig, projectPolicy: policy, issueSettings: null, mode, legacyUseProjectWorkspace: null }); + } await db.update(projectWorkspaces).set({ cwd: null, sourceType: "remote_managed", repoUrl: "https://github.com/example/repo.git" }).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!)); await rm(source, { recursive: true }); const sequence: string[] = []; @@ -124,8 +139,41 @@ 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: "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", + manifestJson: { id: "test.default-storage", apiVersion: 1, version: "1.0.0", displayName: "Default storage", description: "Storage", author: "Test", categories: ["workspace"], + workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"], defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } }, + capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + return plugin; + } + it("keeps source-free project tasks outside installed provider preparation", async () => { + const { input, issue } = await fixture(); + await installDefault(); + await db.update(issues).set({ projectWorkspaceId: null }).where(eq(issues.id, issue.id)); + await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!)); + input.task.issueRef = { ...issue, projectWorkspaceId: null }; + input.policy.projectExecutionWorkspacePolicy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, false); + const call = vi.fn(); + input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any; + const prepared = await prepareHeartbeatWorkspace(db, input); + expect(prepared.executionWorkspace.cwd).toBeTruthy(); + expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin"); + expect(call).not.toHaveBeenCalled(); + }); + it("pins a resolved Core choice through source selection when a default appears during setup", async () => { + const { input, source } = await fixture(); + expect(input.policy.projectExecutionWorkspacePolicy).toBeNull(); + await installDefault(); + const call = vi.fn(); + input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any; + const prepared = await prepareHeartbeatWorkspace(db, input); + expect(prepared.executionWorkspace.cwd).toBe(source); + expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin"); + expect(call).not.toHaveBeenCalled(); + }); + const readIssue = (id: string) => db.select().from(issues).where(eq(issues.id, id)).then(rows => rows[0]!); const readRun = (id: string) => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)).then(rows => rows[0]!); const leases = (id: string) => db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, id)); @@ -135,6 +183,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", } }).where(eq(projects.id, issue.projectId!)); + input.policy.projectExecutionWorkspacePolicy = { enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }; input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] }; const call = vi.fn(); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 21e0d0c44da..8b1cca96500 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -27,7 +27,7 @@ import path from "node:path"; import { CONTROLLER_PROVISION_COPY_ONLY, workspaceRequiresCopyOnlyProvisioning } from "../workspace-host-provision.js"; import { and, asc, eq } from "drizzle-orm"; import { type ExecutionWorkspace } from "@paperclipai/shared"; -import { heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; +import { heartbeatRuns, issues, projectWorkspaces } from "@paperclipai/db"; import { getStartupTracer } from "../../instrumentation.js"; import { createHostDuplexObservabilityRecorder } from "../duplex-observability-recorder.js"; import { incrementToolRuntimeMetricCounter } from "../tool-runtime-metrics.js"; @@ -330,6 +330,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default", configuredCwd: readNonEmptyString(mergedConfig.cwd), + storageProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null, anchorWorkspace: !pluginTaskWorkspace && requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree" ? await resolveReusedGitWorkspaceAnchor({ agent, @@ -375,14 +376,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace })); const hostProvisionDriver = selectedEnvironmentForConfig?.driver ?? lowTrustPreflightEnvironmentDriver; if (fresh && !isDotRun) { - const [project] = resolvedWorkspace.projectId ? await db.select({ - policy: projects.executionWorkspacePolicy, - }).from(projects).where(and(eq(projects.id, resolvedWorkspace.projectId), - eq(projects.companyId, agent.companyId))) : []; const sourceWorkspace = reusableExistingExecutionWorkspace ?? existingExecutionWorkspace; if (sourceWorkspace?.providerType === "plugin" || readWorkspaceProviderBinding(sourceWorkspace?.metadata) || - project?.policy?.storageProviderPluginId) { + (resolvedWorkspace.workspaceId && projectExecutionWorkspacePolicy?.storageProviderPluginId)) { throw new WorkspaceValidationFailure("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", { reason: "fresh_workspace_storage_unsupported", }); @@ -392,6 +389,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace companyId: agent.companyId, projectId: resolvedWorkspace.projectId, projectWorkspaceId: resolvedWorkspace.workspaceId, issueId, runId: run.id, agentId: agent.id, responsibleUserId, existingWorkspaceId: reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, + selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null, mode: requestedExecutionWorkspaceMode, strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig), branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create", diff --git a/server/src/services/heartbeat/workspaces.ts b/server/src/services/heartbeat/workspaces.ts index fa12c39490a..5e4ef54f870 100644 --- a/server/src/services/heartbeat/workspaces.ts +++ b/server/src/services/heartbeat/workspaces.ts @@ -1,3 +1,4 @@ +import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js"; import { canonicalRepositoryRoot } from "../execution-workspace-source-roots.js"; import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js"; import { selectRestoredProjectRepositories, type GitWorkspaceContents } from "../git-workspace-contents.js"; @@ -3406,7 +3407,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) { agent: typeof agents.$inferSelect, context: Record, previousSessionParams: Record | null, - opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null }, + opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null }, ): Promise { const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); @@ -3508,8 +3509,11 @@ export function createHeartbeatWorkspaceResolver(db: Db) { } const [sourceProject] = projectWorkspaceRows.length ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects) .where(and(eq(projects.id, workspaceProjectId!), eq(projects.companyId, agent.companyId))) : []; + const storageProviderPluginId = opts?.storageProviderPluginId !== undefined ? opts.storageProviderPluginId : sourceProject?.policy?.storageProviderPluginId ?? + (sourceProject ? (await effectiveProjectWorkspacePolicy(db, agent.companyId, sourceProject.policy, + projectWorkspaceRows.some(workspace => Boolean(workspace.repoUrl))))?.storageProviderPluginId : null); for (const workspace of projectWorkspaceRows) { - if (sourceProject?.policy?.storageProviderPluginId) { + if (storageProviderPluginId) { return { cwd: `/workspaces/${workspace.id}`, source: "project_primary" as const, projectId: workspace.projectId, workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [] }; @@ -3676,6 +3680,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; + storageProviderPluginId?: string | null; anchorWorkspace?: ResolvedAnchorWorkspaceForRun; }, ): Promise { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b95d7058b04..ff1aac804be 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10235,7 +10235,7 @@ export function issueService(db: Db) { if (projectWorkspaceId && isolatedWorkspacesEnabled) { const sourceStrategy = await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId, assigneeAgentId: issueData.assigneeAgentId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides, - executionWorkspaceSettings, executionWorkspacePolicy: projectPolicy, + executionWorkspaceSettings, executionWorkspacePolicy: projectPolicy, sourceWorkspaceId: projectWorkspaceId, typedSelection: false, projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx); if (sourceStrategy === "git_worktree") workspaceIntent = { version: 1, source: "project", selection: { kind: "configured_source", projectWorkspaceId, mode: "managed_isolated" } }; @@ -10248,7 +10248,7 @@ export function issueService(db: Db) { if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible"); await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId, assigneeAgentId: issueData.assigneeAgentId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides, - executionWorkspaceSettings, executionWorkspacePolicy: readableSource.executionWorkspacePolicy, + executionWorkspaceSettings, executionWorkspacePolicy: readableSource.executionWorkspacePolicy, sourceWorkspaceId: projectWorkspaceId, typedSelection: Boolean(workspaceIntent), projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx); await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId); } diff --git a/server/src/services/plugin-workspaces.ts b/server/src/services/plugin-workspaces.ts index 70715598d93..e519bd7123a 100644 --- a/server/src/services/plugin-workspaces.ts +++ b/server/src/services/plugin-workspaces.ts @@ -1,3 +1,4 @@ +import { effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js"; import { randomUUID, createHash } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { @@ -56,9 +57,7 @@ async function provider( !plugin || plugin.status !== "ready" || settings?.enabled === false || - !plugin.manifestJson.workspaceProvider || - !plugin.manifestJson.capabilities.includes("workspaces.manage") || - !plugin.manifestJson.resourceLifecycle?.includes("project") || + !isWorkspaceProvider(plugin.manifestJson) || !workers .getWorker(pluginId) ?.supportedMethods?.includes("workspaceOperation") @@ -164,6 +163,7 @@ export async function preparePluginWorkspace( existingWorkspaceId: string | null; mode: string; strategy: string; + selectedProviderPluginId?: string | null; branchMode?: "create" | "existing"; branchName?: string | null; baseRef?: string | null; @@ -185,6 +185,7 @@ export async function preparePluginWorkspace( existing?.metadata?.workspaceProviderRemoval ) throw conflict("Workspace storage is retired or awaiting cleanup"); + if (!existing && (!input.projectId || !input.projectWorkspaceId)) return null; const saved = readWorkspaceProviderBinding(existing?.metadata); const mode = existing?.mode ?? input.mode, strategy = existing?.strategyType ?? input.strategy; @@ -200,10 +201,10 @@ export async function preparePluginWorkspace( ), ) : []; - const owner = - saved?.pluginId ?? - existing?.metadata?.workspaceProviderOwner ?? - project?.executionWorkspacePolicy?.storageProviderPluginId; + if (existing && existing.providerType !== "plugin" && !saved && !existing.metadata?.workspaceProviderOwner) return null; + const owner = saved?.pluginId ?? existing?.metadata?.workspaceProviderOwner ?? + (!existing && input.selectedProviderPluginId !== undefined ? input.selectedProviderPluginId : + !existing && project ? (await effectiveProjectWorkspacePolicy(db, input.companyId, project.executionWorkspacePolicy, false))?.storageProviderPluginId : null); const pluginId = typeof owner === "string" ? owner : null; if (!pluginId) { if (existing?.providerType === "plugin") @@ -341,7 +342,7 @@ export async function preparePluginWorkspace( if ( !currentProject || currentProject.archivedAt || - currentProject.executionWorkspacePolicy?.storageProviderPluginId !== + (await effectiveProjectWorkspacePolicy(tx, input.companyId, currentProject.executionWorkspacePolicy, false))?.storageProviderPluginId !== pluginId || !currentSource || currentSource.repoUrl !== source.repoUrl || diff --git a/server/src/services/project-workspace-provider.ts b/server/src/services/project-workspace-provider.ts new file mode 100644 index 00000000000..cc8e8272b2c --- /dev/null +++ b/server/src/services/project-workspace-provider.ts @@ -0,0 +1,72 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db"; +import type { PaperclipPluginManifestV1, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared"; +import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { conflict } from "../errors.js"; + +export function isWorkspaceProvider(manifest: PaperclipPluginManifestV1) { + return Boolean(manifest.workspaceProvider && manifest.capabilities.includes("workspaces.manage") && + manifest.resourceLifecycle?.includes("project")); +} + +type ProviderDb = Pick; + +export async function listProjectWorkspaceProviders(db: ProviderDb, companyId: string) { + const rows = await db.select({ plugin: plugins, enabled: pluginCompanySettings.enabled }) + .from(plugins) + .leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.pluginId, plugins.id), + eq(pluginCompanySettings.companyId, companyId))) + .where(notInArray(plugins.status, ["disabled", "uninstalled"])); + return rows.filter(row => row.enabled !== false && isWorkspaceProvider(row.plugin.manifestJson)) + .map(row => row.plugin); +} + +type Provider = Awaited>[number]; + +export function resolveProjectWorkspaceProvider( + rawPolicy: unknown, + providers: Provider[], + hasGitSource: boolean, +): ProjectWorkspaceProviderConfiguration { + const policy = parseProjectExecutionWorkspacePolicy(rawPolicy); + const explicit = policy?.storageProviderPluginId !== undefined; + const defaults = providers.filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects); + const selected = explicit ? providers.find(plugin => plugin.id === policy?.storageProviderPluginId) + : defaults.length === 1 ? defaults[0] : undefined; + const pluginId = explicit ? policy?.storageProviderPluginId ?? null : selected?.id ?? null; + let error = !explicit && defaults.length > 1 + ? `Multiple workspace storage plugins claim the project default: ${defaults.map(plugin => plugin.manifestJson.displayName).sort().join(", ")}. Select a storage provider in project configuration or disable a conflicting default.` + : null; + const declaration = selected?.manifestJson.workspaceProvider; + const supplied = hasGitSource ? declaration?.defaultForProjects : undefined; + const effectivePolicy = pluginId ? parseProjectExecutionWorkspacePolicy({ + enabled: true, + ...(supplied ? { defaultMode: supplied.mode } : {}), + ...policy, + storageProviderPluginId: pluginId, + ...(supplied ? { workspaceStrategy: { type: supplied.strategy, ...policy?.workspaceStrategy } } : {}), + }) : policy; + if (pluginId && !selected) error = "The workspace storage plugin is unavailable"; + if (declaration && effectivePolicy && hasGitSource && ( + effectivePolicy.enabled === false || + (effectivePolicy.defaultMode && !declaration.modes.includes(effectivePolicy.defaultMode as "isolated_workspace")) || + (effectivePolicy.workspaceStrategy?.type && !declaration.strategies.includes(effectivePolicy.workspaceStrategy.type as "git_worktree")) + )) { + error = "The storage plugin does not support the explicit project workspace settings. Change the workspace mode or strategy, or select another storage provider in project configuration."; + } + return { + source: explicit ? "project" : selected || error ? "default" : "core", + pluginId, + displayName: selected?.manifestJson.displayName ?? (pluginId ? "Unavailable provider" : "Core / local"), + providers: providers.map(plugin => ({ id: plugin.id, displayName: plugin.manifestJson.displayName, + isDefault: Boolean(plugin.manifestJson.workspaceProvider?.defaultForProjects) })), + effectivePolicy, + error, + }; +} + +export async function effectiveProjectWorkspacePolicy(db: ProviderDb, companyId: string, rawPolicy: unknown, hasGitSource: boolean) { + const configuration = resolveProjectWorkspaceProvider(rawPolicy, await listProjectWorkspaceProviders(db, companyId), hasGitSource); + if (configuration.error) throw conflict(configuration.error); + return configuration.effectivePolicy; +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index acb4121a6dc..7f6795d2132 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,3 +1,4 @@ +import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js"; import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { enqueueProjectSkillSource, detachProjectSkillSource } from "./project-skill-source-bindings.js"; @@ -138,6 +139,7 @@ interface ProjectWithGoals extends Omit goalIds: string[]; goals: ProjectGoalRef[]; executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; + workspaceProvider?: import("@paperclipai/shared").ProjectWorkspaceProviderConfiguration; codebase: ProjectCodebase; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; @@ -311,6 +313,7 @@ function pickPrimaryWorkspace( async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise { if (rows.length === 0) return []; + const providerCatalog = await listProjectWorkspaceProviders(db, rows[0]!.companyId); const projectIds = rows.map((r) => r.id); const workspaceRows = await db .select() @@ -393,6 +396,8 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise Boolean(workspace.repoUrl))), }; }); } @@ -974,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); diff --git a/ui/src/components/ProjectProperties.concurrency.test.tsx b/ui/src/components/ProjectProperties.concurrency.test.tsx index a59924c2e90..ec8c14cac2e 100644 --- a/ui/src/components/ProjectProperties.concurrency.test.tsx +++ b/ui/src/components/ProjectProperties.concurrency.test.tsx @@ -138,3 +138,36 @@ it("hides project isolation controls without rewriting its policy", () => { expect(container.querySelector('select[aria-label="Shared workspace concurrency"]')).toBeNull(); expect(onFieldUpdate).not.toHaveBeenCalled(); }); + + +describe("ProjectProperties — workspace storage selection", () => { + const inherited = { + source: "default" as const, pluginId: "provider-one", displayName: "Managed storage", error: null, + effectivePolicy: { enabled: true, defaultMode: "isolated_workspace" as const, storageProviderPluginId: "provider-one", workspaceStrategy: { type: "git_worktree" as const } }, + providers: [{ id: "provider-one", displayName: "Managed storage", isDefault: true }, { id: "provider-two", displayName: "Alternative", isDefault: false }], + }; + function storageSelect() { return container.querySelector('select[aria-label="Workspace storage provider"]')!; } + it("displays the inherited default without saving it as an override", () => { + render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn()); + expect(storageSelect().value).toBe("inherit"); + expect(container.textContent).toContain("Inherited default: Managed storage"); + }); + it.each(["core", "provider-two", "inherit"])("saves the deliberate %s choice while preserving explicit settings", value => { + const onFieldUpdate = vi.fn(); + render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one", sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate); + act(() => { storageSelect().value = value; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); }); + const policy = { enabled: true, sharedWorkspaceConcurrency: "serialize", ...(value === "inherit" ? {} : { storageProviderPluginId: value === "core" ? null : value }) }; + expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: policy }); + }); + it("does not copy inherited mode, strategy, or provider when another setting changes", () => { + const onFieldUpdate = vi.fn(); + render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), onFieldUpdate); + act(() => { concurrencySelect().value = "allow"; concurrencySelect().dispatchEvent(new Event("change", { bubbles: true })); }); + expect(onFieldUpdate).toHaveBeenCalledWith("execution_workspace_shared_concurrency", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "allow" } }); + }); + it("shows default conflicts and keeps the provider control usable", () => { + render(makeProject({ workspaceProvider: { ...inherited, error: "Select a storage provider in project configuration" } }), vi.fn()); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Select a storage provider"); + expect(storageSelect().disabled).toBe(false); + }); +}); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index aa65c10ee50..a1c631c1546 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -47,6 +47,7 @@ export type ProjectConfigFieldKey = | "visibility" | "goals" | "env" + | "workspace_storage_provider" | "execution_workspace_enabled" | "execution_workspace_default_mode" | "execution_workspace_shared_concurrency" @@ -273,7 +274,9 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda const codebase = project.codebase; const primaryCodebaseWorkspace = project.primaryWorkspace ?? null; const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId); - const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null; + const storedExecutionWorkspacePolicy = project.executionWorkspacePolicy ?? null; + const providerConfiguration = project.workspaceProvider; + const executionWorkspacePolicy = providerConfiguration?.effectivePolicy ?? storedExecutionWorkspacePolicy; const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const executionWorkspaceDefaultMode = @@ -352,9 +355,7 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda return { executionWorkspacePolicy: { enabled: executionWorkspacesEnabled, - defaultMode: executionWorkspaceDefaultMode, - allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true, - ...executionWorkspacePolicy, + ...storedExecutionWorkspacePolicy, ...patch, }, }; @@ -729,6 +730,41 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda )} } + {providerConfiguration ? ( +
+ + +

+ {providerConfiguration.source === "default" ? `Inherited default: ${providerConfiguration.displayName}` : `Selected: ${providerConfiguration.displayName}`} +

+ {providerConfiguration.error ?

{providerConfiguration.error}

: null} +
+ ) : null} + {isolatedWorkspacesEnabled && workspaceIsolationControlsVisible ? ( <>