From efc54541f600918ac9ec1ac4391beab726575667 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 22 Oct 2026 16:07:32 -0700 Subject: [PATCH] Support installed default project workspace providers Resolve inherited provider and Git workspace defaults through a generic manifest contract. Preserve explicit Core/local and plugin selections, durable workspace ownership, and unavailable-provider failures. Expose effective defaults separately in project configuration. Verified focused provider, ownership, API, manifest, heartbeat and UI tests, plus TypeScript checks for the affected packages. Full repository gates are delegated to GitHub CI. Co-Authored-By: Paperclip --- maps/agent-access-map/projects.md | 6 + maps/feature-map/projects.md | 4 + packages/plugins/sdk/README.md | 35 +++- .../sdk/tests/workspace-provider.test.ts | 7 + packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/plugin.ts | 5 + packages/shared/src/types/project.ts | 10 ++ .../shared/src/types/workspace-runtime.ts | 2 + packages/shared/src/validators/plugin.ts | 8 + packages/shared/src/validators/project.ts | 1 + ...ect-workspace-provider.integration.test.ts | 164 ++++++++++++++++++ .../services/execution-workspace-policy.ts | 2 +- server/src/services/execution-workspaces.ts | 21 ++- server/src/services/heartbeat.ts | 39 +++-- .../heartbeat/workspace-preparation.test.ts | 21 ++- .../heartbeat/workspace-preparation.ts | 5 +- server/src/services/heartbeat/workspaces.ts | 6 +- server/src/services/issues.ts | 4 +- server/src/services/plugin-workspaces.ts | 17 +- .../services/project-workspace-provider.ts | 74 ++++++++ server/src/services/projects.ts | 5 + .../ProjectProperties.concurrency.test.tsx | 33 ++++ ui/src/components/ProjectProperties.tsx | 44 ++++- 24 files changed, 465 insertions(+), 50 deletions(-) create mode 100644 server/src/__tests__/project-workspace-provider.integration.test.ts create mode 100644 server/src/services/project-workspace-provider.ts 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..532f71e8e02 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -1315,10 +1315,37 @@ 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. + +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`. 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 613add068bc..c7e41b893ea 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..030f58bf2f9 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -698,6 +698,11 @@ export interface PaperclipPluginManifestV1 { workspaceProvider?: { modes: Array<"shared_workspace" | "isolated_workspace" | "operator_branch">; strategies: Array<"project_primary" | "git_worktree">; + /** Inherit this provider and these Git workspace defaults for projects without an explicit selection. */ + 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..e36c463e26c 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -171,6 +171,8 @@ export interface ExecutionWorkspaceCloseReadiness { export interface ProjectExecutionWorkspacePolicy { enabled: boolean; + /** Omitted inherits the installed default; null explicitly selects Core/local. */ + 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/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 61839f7a1b5..6ea254cd4d3 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -29,6 +29,7 @@ export const projectExecutionWorkspacePolicySchema = z allowIssueOverride: z.boolean().optional(), defaultProjectWorkspaceId: z.string().guid().optional().nullable(), environmentId: z.string().guid().optional().nullable(), + // Omitted inherits; null explicitly selects Core/local. storageProviderPluginId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), 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..5353633269e --- /dev/null +++ b/server/src/__tests__/project-workspace-provider.integration.test.ts @@ -0,0 +1,164 @@ +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 { 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 { 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("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..e6d7d3741f9 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 } : {}), diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 91f7a0d03d3..d3c1694f27f 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"; @@ -1404,6 +1405,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), @@ -1413,12 +1415,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 }) @@ -1475,7 +1486,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 }; @@ -1565,7 +1576,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}}" }; @@ -1634,7 +1645,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..efe6b1cc44f 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,20 @@ 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([false, true])("prepares managed storage before acquisition (inherited default: %s) without touching a Core checkout", async inherited => { 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: "isolated_workspace" as const, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: plugin.id } }).where(eq(projects.id, issue.projectId!)); + if (inherited) { + const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, 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: {}, 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,7 +133,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: "test-WSP-1" }); + expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" }); }); 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]!); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 21e0d0c44da..e9459059c67 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -1,3 +1,4 @@ +import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js"; import { attachPreservedWorkspaceSnapshots } from "../workspace-recovery-snapshot.js"; import { materializeFreshWorkspaceSeed } from "../fresh-workspace-seed.js"; import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js"; @@ -382,7 +383,8 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace const sourceWorkspace = reusableExistingExecutionWorkspace ?? existingExecutionWorkspace; if (sourceWorkspace?.providerType === "plugin" || readWorkspaceProviderBinding(sourceWorkspace?.metadata) || - project?.policy?.storageProviderPluginId) { + project?.policy?.storageProviderPluginId || + (project && !sourceWorkspace && (await effectiveProjectWorkspacePolicy(db, agent.companyId, project.policy, false))?.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 +394,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, 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..26165e1f9f6 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"; @@ -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 = 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: [] }; 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..f54c05f0346 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; @@ -200,10 +200,11 @@ export async function preparePluginWorkspace( ), ) : []; - const owner = - saved?.pluginId ?? - existing?.metadata?.workspaceProviderOwner ?? - project?.executionWorkspacePolicy?.storageProviderPluginId; + // A project default can only select storage for a new workspace. + 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..26df8244b6f --- /dev/null +++ b/server/src/services/project-workspace-provider.ts @@ -0,0 +1,74 @@ +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")); +} + +/** Installation/enablement select the provider; transient worker health must never change it. */ +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]; + +/** Keep stored policy separate from effective defaults so reads and edits never turn inheritance into an override. */ +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..b23e32875d8 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))), }; }); } 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 ? ( <>