diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 5e7e72998a9..71c79e37742 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -129,8 +129,9 @@ async onResourceLifecycle(event) { ``` Core delivers events to ready plugins with a configuration row for that company, -unless the plugin is disabled there. Each invocation has the event's company -scope. The dispatcher wakes after the mutation's outer transaction settles, +unless the plugin is disabled there. A workspace storage provider receives project +events only for projects attached to it, plus events for deleted projects. Each +invocation has the event's company scope. The dispatcher wakes after the mutation's outer transaction settles, on host startup, plugin activation/worker start, and company configuration or enablement changes. Successful worker `onConfigChanged` completion wakes delivery again, so a callback that deferred while configuration was still applying is diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 415a7bfbf98..683aa80464b 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -1398,10 +1398,10 @@ 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 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: +`project.executionWorkspacePolicy.storageProviderPluginId` holds a project's storage: +an installed plugin's UUID, or `null` or no value for Core/local storage. Core +applies the installed company-enabled default once, when it creates the project. A +provider claims that default by declaring, for example: ```ts workspaceProvider: { @@ -1411,13 +1411,16 @@ workspaceProvider: { } ``` -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. +The declared defaults must be supported by the provider. Installing a provider +does not change existing projects. A Core/local project can move to a provider, and +a plugin-backed project stays attached to that plugin: requests to change it fail, +and it never falls back to local storage. Core applies missing mode and strategy +settings without writing them. Explicit settings remain intact; incompatible +settings fail with a configuration error. If more than one enabled provider claims +the default, creating a project needs an explicit choice. Core sends a provider +project lifecycle events only for projects attached to it, and acknowledges the +others on its behalf. 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 diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 346aede02c4..43a5232bdd1 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -108,7 +108,7 @@ export interface ProjectDiscoveryPage { } export interface ProjectWorkspaceProviderConfiguration { - source: "project" | "default" | "core"; + source: "project" | "core"; pluginId: string | null; displayName: string; providers: Array<{ id: string; displayName: string; isDefault: boolean }>; diff --git a/server/src/__tests__/heartbeat-session-preparation.test.ts b/server/src/__tests__/heartbeat-session-preparation.test.ts index 2d117a6855c..c9fa1b5bc09 100644 --- a/server/src/__tests__/heartbeat-session-preparation.test.ts +++ b/server/src/__tests__/heartbeat-session-preparation.test.ts @@ -89,14 +89,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => expect(resumed.finished.sessionIdBefore).toBe("saved-session"); }); - it("keeps inherited project policy stable after binding a repository-free workspace", async () => { + it("keeps a default-attached project policy stable after binding a repository-free workspace", async () => { const f = await seed(); const storage = await createPluginWorkspaceFixture(db, f.companyId); const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId)); await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" }, } } }).where(eq(plugins.id, storage.pluginId)); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Directory project" }).returning(); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Directory project", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).returning(); await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId)); try { const first = await execute(f); @@ -116,14 +116,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => null, { mode: "isolated_workspace" }, { workspaceStrategy: { type: "git_worktree", branchTemplate: "work" } }, - ])("keeps inherited repository policy stable through binding with settings %j and retains the provider on policy changes", async (settings) => { + ])("keeps a default-attached repository policy stable through binding with settings %j and retains the provider on policy changes", async (settings) => { const f = await seed(); const storage = await createPluginWorkspaceFixture(db, f.companyId); const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId)); await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" }, } } }).where(eq(plugins.id, storage.pluginId)); - await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, storage.input.projectId)); + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId)); await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId, executionWorkspaceSettings: settings, }).where(eq(issues.id, f.issueId)); @@ -158,7 +158,7 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => workspace: { action: "reuse", workspaceReused: true, changedCategories: [], activeWorkspaceId: bound.executionWorkspaceId }, } }); } - await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, allowIssueOverride: false } }).where(eq(projects.id, storage.input.projectId)); + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, allowIssueOverride: false, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId)); const changed = await execute(f); expect(changed.input.runtime.sessionId).toBeNull(); expect(changed.finished.resultJson).toMatchObject({ configFreshness: { session: { reset: true, changedCategories: ["workspaceConfig"] } } }); diff --git a/server/src/__tests__/plugin-lifecycle-delivery.test.ts b/server/src/__tests__/plugin-lifecycle-delivery.test.ts index 7889dcd6e72..8e5e5b70bef 100644 --- a/server/src/__tests__/plugin-lifecycle-delivery.test.ts +++ b/server/src/__tests__/plugin-lifecycle-delivery.test.ts @@ -54,6 +54,40 @@ describePostgres("durable plugin lifecycle delivery", () => { const worker = registerPluginLifecycleDelivery(coordinator as never, db, { call, isRunning: running, onConfigApplied } as unknown as PluginWorkerManager, lifecycle as unknown as PluginLifecycleManager); return { call, running, wake, lifecycle, worker, applied: () => { for (const listener of configApplied) listener(); }, run: () => task.run(new AbortController().signal), pending: () => task.hasPending(), nextRunAt: () => task.nextRunAt?.(), settled: () => task.onSettled?.() }; } + it("delivers project events to a storage provider only for projects attached to it", async () => { + await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "workspaces.manage"], workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"] } } as never }).where(eq(plugins.id, pluginId)); + const [core] = await db.insert(projects).values({ companyId, name: "Core project" }).returning(); + const [attached] = await db.insert(projects).values({ companyId, name: "Attached project", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } }).returning(); + await event(core.id); + await event(attached.id); + const d = delivery(); + try { + await d.run(); + expect(d.call).toHaveBeenCalledTimes(1); + expect(d.call.mock.calls[0][2]).toMatchObject({ resourceId: attached.id }); + expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]); + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } }).where(eq(projects.id, core.id)); + await event(core.id, "update"); + await d.run(); + expect(d.call).toHaveBeenCalledTimes(2); + expect(d.call.mock.calls[1][2]).toMatchObject({ resourceId: core.id, action: "update" }); + } finally { + d.worker.dispose(); + } + }); + + it("delivers a deleted project's event to a storage provider", async () => { + await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "workspaces.manage"], workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"] } } as never }).where(eq(plugins.id, pluginId)); + const removed = await event(randomUUID(), "delete"); + const d = delivery(); + try { + await d.run(); + expect(d.call.mock.calls[0][2]).toMatchObject({ resourceId: removed, action: "delete" }); + } finally { + d.worker.dispose(); + } + }); + it("supplies project native credentials only to opted-in callbacks and saves returned patches before acknowledgment", async () => { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "projects.instance-secrets"] } as never }).where(eq(plugins.id, pluginId)); diff --git a/server/src/__tests__/plugin-workspaces.integration.test.ts b/server/src/__tests__/plugin-workspaces.integration.test.ts index b0273732622..b72381db25f 100644 --- a/server/src/__tests__/plugin-workspaces.integration.test.ts +++ b/server/src/__tests__/plugin-workspaces.integration.test.ts @@ -558,8 +558,10 @@ const support = await getEmbeddedPostgresTestSupport(); const workspace = await preparePluginWorkspace(db, f.workers, f.input); const svc = projectService(db); await expect( - svc.update(f.input.projectId, { executionWorkspacePolicy: {} }), - ).rejects.toThrow("storage owner"); + svc.update(f.input.projectId, { + executionWorkspacePolicy: { storageProviderPluginId: null }, + }), + ).rejects.toThrow("stays attached"); await expect( svc.updateWorkspace(f.input.projectId, f.input.projectWorkspaceId, { repoUrl: "https://github.com/example/other.git", diff --git a/server/src/__tests__/project-workspace-provider.integration.test.ts b/server/src/__tests__/project-workspace-provider.integration.test.ts index 0d7cc1ad8f8..deab86e3248 100644 --- a/server/src/__tests__/project-workspace-provider.integration.test.ts +++ b/server/src/__tests__/project-workspace-provider.integration.test.ts @@ -6,13 +6,13 @@ 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 { effectiveProjectWorkspacePolicy, listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, sourcelessProjectWorkspacePolicy } 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 { ensurePersonalPrivateProject, projectService } from "../services/projects.js"; import { projectRoutes } from "../routes/projects.js"; import { errorHandler } from "../middleware/index.js"; @@ -33,33 +33,55 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 () => { + it("attaches a new project to the installed default and leaves existing projects where they are", async () => { const f = await fixture(); + await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId)); 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" }); + expect(created.executionWorkspacePolicy).toMatchObject({ enabled: true, storageProviderPluginId: f.pluginId }); + expect(created.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId }); + const existing = await svc.getById(f.input.projectId); + expect(existing!.executionWorkspacePolicy).toBeNull(); + expect(existing!.workspaceProvider).toMatchObject({ source: "core", pluginId: null }); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toBeNull(); + expect(await preparePluginWorkspace(db, f.workers, { ...f.input, selectedProviderPluginId: null })).toBeNull(); + expect(f.calls).toHaveLength(0); + }); + + it("creates a project on Core storage when no default is installed, and keeps it there after one arrives", async () => { + const f = await createPluginWorkspaceFixture(db); + const svc = projectService(db); + const created = await svc.create(f.companyId, { name: "Before the provider" }); + expect(created.executionWorkspacePolicy).toBeNull(); + 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)); + expect((await svc.getById(created.id))!.workspaceProvider).toMatchObject({ source: "core", pluginId: null }); + }); + + it("keeps an explicit creation choice", async () => { + const f = await fixture(); + const other = await createPluginWorkspaceFixture(db, f.companyId); + const svc = projectService(db); + expect((await svc.create(f.companyId, { name: "Local", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).workspaceProvider) + .toMatchObject({ source: "project", pluginId: null }); + expect((await svc.create(f.companyId, { name: "Other", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: other.pluginId } })).workspaceProvider) + .toMatchObject({ source: "project", pluginId: other.pluginId }); + }); + + it("keeps a personal project on the installed default", async () => { + const f = await fixture(); + const project = await ensurePersonalPrivateProject(db, f.companyId, "user-one"); + expect(project.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId }); }); 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 }); @@ -72,7 +94,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = } 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 policy = await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, 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 }); @@ -82,17 +104,28 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 () => { + it("keeps a plugin-backed project attached through every policy update", async () => { const f = await fixture(); + const other = await createPluginWorkspaceFixture(db, f.companyId); const workspace = await preparePluginWorkspace(db, f.workers, f.input); const svc = projectService(db); + const kept = await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" } }); + expect(kept!.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId, sharedWorkspaceConcurrency: "serialize" }); + expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: null }))!.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId }); + for (const storageProviderPluginId of [null, other.pluginId]) { + await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } })) + .rejects.toThrow("stays attached"); + } + expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace); + }); + + it("lets a Core project move to a storage plugin, and then keeps it there", async () => { + const f = await fixture(); + await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId)); + 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"); + await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).rejects.toThrow("stays attached"); }); it("preserves explicit alternatives and the explicit Core/local choice", async () => { @@ -101,16 +134,16 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 }); + await db.update(projects).set({ executionWorkspacePolicy: local }).where(eq(projects.id, f.input.projectId)); expect((await projectService(db).getById(f.input.projectId))!.executionWorkspacePolicy).toEqual(local); - expect(await preparePluginWorkspace(db, f.workers, f.input)).toBeNull(); + expect(await preparePluginWorkspace(db, f.workers, { ...f.input, selectedProviderPluginId: null })).toBeNull(); expect(f.calls).toHaveLength(0); }); - it.each(["disabled", "uninstalled"] as const)("does not inherit an instance %s provider", async status => { + it.each(["disabled", "uninstalled"] as const)("does not attach a new project to 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(); + expect((await projectService(db).create(f.companyId, { name: "New project" })).executionWorkspacePolicy).toBeNull(); }); it("scopes company enablement and does not borrow another company's override", async () => { @@ -118,8 +151,9 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 }); + const svc = projectService(db); + expect((await svc.create(f.companyId, { name: "Disabled here" })).executionWorkspacePolicy).toBeNull(); + expect((await svc.create(other.id, { name: "Enabled here" })).executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId }); await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true)) .rejects.toThrow("unavailable"); }); @@ -127,30 +161,30 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 }); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, 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 () => { + it("never falls back when the attached provider is disabled during preparation", async () => { const f = await fixture(); - const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, null, true); + const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, 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 () => { + it("asks for a choice when creating a project with conflicting defaults, and leaves existing projects working", 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 }); + const svc = projectService(db); + await expect(svc.create(f.companyId, { name: "Ambiguous" })).rejects.toThrow("Multiple workspace storage plugins"); + expect((await svc.create(f.companyId, { name: "Chosen", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })).workspaceProvider) + .toMatchObject({ pluginId: f.pluginId, error: null }); + expect((await svc.getById(f.input.projectId))!.workspaceProvider).toMatchObject({ pluginId: f.pluginId, error: null }); }); it.each([ @@ -159,14 +193,24 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = { 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"); + await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { ...policy, storageProviderPluginId: f.pluginId }, true)).rejects.toThrow("explicit project workspace settings"); + }); + + it("ignores saved storage for a project with no source", () => { + const pluginId = randomUUID(); + expect(sourcelessProjectWorkspacePolicy(null)).toBeNull(); + expect(sourcelessProjectWorkspacePolicy({ enabled: true, storageProviderPluginId: pluginId })).toBeNull(); + expect(sourcelessProjectWorkspacePolicy({ enabled: false, storageProviderPluginId: null })).toBeNull(); + expect(sourcelessProjectWorkspacePolicy({ enabled: true })).toEqual({ enabled: true }); + expect(sourcelessProjectWorkspacePolicy({ enabled: true, defaultMode: "isolated_workspace", storageProviderPluginId: pluginId })) + .toEqual({ enabled: true, defaultMode: "isolated_workspace" }); }); 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 }); + const policy = { enabled: true, defaultMode: "operator_branch", storageProviderPluginId: f.pluginId, workspaceStrategy: { type: "git_worktree", baseRef: "release", branchTemplate: "kept" } }; + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, policy, true)).toEqual(policy); + expect(await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, false)).toEqual({ enabled: true, storageProviderPluginId: f.pluginId }); }); it("retains both plugin and local workspace ownership when defaults change", async () => { @@ -183,7 +227,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 () => { + it("exposes attachment through the normal API with company access checks", async () => { const f = await fixture(); const app = express(); app.use(express.json()); @@ -191,14 +235,12 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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 }); + expect((await request(app).get(url)).body.workspaceProvider).toMatchObject({ source: "project", 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 }); + expect(patch.status).toBe(409); + const kept = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true } }); + expect(kept.status).toBe(200); + expect(kept.body.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: 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); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8b467b9d4e9..40595602922 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -468,7 +468,6 @@ import { parseIssueAssigneeAdapterOverrides, gateProjectExecutionWorkspacePolicy, parseIssueExecutionWorkspaceSettings, - parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, selectEnvironmentExecutionWorkspaceSettings, } from "./execution-workspace-policy.js"; @@ -532,7 +531,7 @@ import { import { resolveAndRetainRunTrustPreset } from "./run-trust-preset.js"; -import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js"; +import { effectiveProjectWorkspacePolicy, sourcelessProjectWorkspacePolicy } from "./project-workspace-provider.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js"; @@ -2439,7 +2438,7 @@ export function heartbeatService( ); const parsedProjectExecutionWorkspacePolicy = workspacePolicyContext?.hasWorkspace ? await effectiveProjectWorkspacePolicy(db, agent.companyId, workspacePolicyContext.executionWorkspacePolicy, workspacePolicyContext.hasGitSource) - : parseProjectExecutionWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy); + : sourcelessProjectWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy); const projectExecutionWorkspacePolicy = applyDefaultIsolatedExecutionWorkspacePolicy({ projectPolicy: gateProjectExecutionWorkspacePolicy( diff --git a/server/src/services/heartbeat/remote-workspace-source.test.ts b/server/src/services/heartbeat/remote-workspace-source.test.ts index a096cf73a09..8724baa3538 100644 --- a/server/src/services/heartbeat/remote-workspace-source.test.ts +++ b/server/src/services/heartbeat/remote-workspace-source.test.ts @@ -105,6 +105,17 @@ describe.skipIf(!support.supported)("remote referenced project sources", () => { expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]); }); + it.each([ + { enabled: true, storageProviderPluginId: randomUUID() }, + { enabled: false, storageProviderPluginId: null }, + ])("keeps the private empty source for a project with no repository and saved storage %j", async (policy) => { + const f = await assignedTask(); + await db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId)); + const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null, + projectId: f.projectId, projectWorkspaceId: null }); + expect(result).toMatchObject({ authority: "remote", source: { kind: "empty" }, projectId: null, projectWorkspaceId: null }); + }); + it("does not use a foreign organizational project as private scratch authority", async () => { const f = await assignedTask(), foreignCompanyId = randomUUID(), foreignProjectId = randomUUID(); await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign owner", issuePrefix: foreignCompanyId.slice(0, 8) }); diff --git a/server/src/services/heartbeat/remote-workspace-source.ts b/server/src/services/heartbeat/remote-workspace-source.ts index ae4232036bc..7c0f1601406 100644 --- a/server/src/services/heartbeat/remote-workspace-source.ts +++ b/server/src/services/heartbeat/remote-workspace-source.ts @@ -1,7 +1,8 @@ -import { asc, and, eq, isNull, notExists } from "drizzle-orm"; +import { asc, and, eq, notExists } from "drizzle-orm"; import { projects, projectWorkspaces, type Db } from "@paperclipai/db"; import type { TaskWorkspaceIntent } from "@paperclipai/shared"; import { HttpError } from "../../errors.js"; +import { sourcelessProjectWorkspacePolicy } from "../project-workspace-provider.js"; import { ProjectAccessDeniedError } from "../project-access-denial.js"; import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../task-workspace-access.js"; import { assertSelectedWorkspaceTrust } from "../task-repository-acquisition.js"; @@ -152,13 +153,13 @@ export async function resolveRemoteWorkspaceSource(db: Db, input: { && selected.task.executionWorkspaceId == null && selected.task.projectWorkspaceId == null && selected.task.workspaceSelection == null && selected.task.workspacePendingSelection == null && input.ref == null && input.branchName == null && input.branchMode !== "existing") { - const [metadataProject] = await db.select({ id: projects.id }).from(projects).where(and( - eq(projects.id, projectId), eq(projects.companyId, input.companyId), isNull(projects.executionWorkspacePolicy), + const [metadataProject] = await db.select({ policy: projects.executionWorkspacePolicy }).from(projects).where(and( + eq(projects.id, projectId), eq(projects.companyId, input.companyId), notExists(db.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(and( eq(projectWorkspaces.projectId, projects.id), eq(projectWorkspaces.companyId, input.companyId), ))), )).limit(1); - implicitTaskDirectory = Boolean(metadataProject); + implicitTaskDirectory = Boolean(metadataProject) && sourcelessProjectWorkspacePolicy(metadataProject.policy) === null; } if (sourceId || projectId && !implicitTaskDirectory) throw new WorkspaceValidationFailure( "The selected project has no repository source for a remote workspace.", diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 3134fb688d1..cf32c0b3967 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -795,21 +795,21 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace }); it.each([ - { inherited: false, mode: "isolated_workspace" as const, fresh: false }, - { inherited: false, mode: "isolated_workspace" as const, fresh: true }, - { inherited: true, mode: "isolated_workspace" as const, fresh: false }, - { inherited: true, mode: "operator_branch" as const, fresh: false }, - ])("prepares managed storage before acquisition (inherited: $inherited, mode: $mode, fresh: $fresh) without touching a Core checkout", async ({ inherited, mode: defaultMode, fresh }) => { + { fromDefault: false, mode: "isolated_workspace" as const, fresh: false }, + { fromDefault: false, mode: "isolated_workspace" as const, fresh: true }, + { fromDefault: true, mode: "isolated_workspace" as const, fresh: false }, + { fromDefault: true, mode: "operator_branch" as const, fresh: false }, + ])("prepares managed storage before acquisition (fromDefault: $fromDefault, mode: $mode, fresh: $fresh) without touching a Core checkout", async ({ fromDefault, mode: defaultMode, fresh }) => { const { input, source, issue } = await fixture(true); - const pluginKey = `test.managed-storage-${inherited}-${defaultMode}-${fresh}`; + const pluginKey = `test.managed-storage-${fromDefault}-${defaultMode}-${fresh}`; const [plugin] = await db.insert(plugins).values({ pluginKey, packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"], manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"], - workspaceProvider: { freshWorkspaceVersion: 1, 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, + workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + const savedPolicy = fromDefault ? { enabled: true, storageProviderPluginId: plugin.id } : { 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!)); + await db.update(projects).set({ executionWorkspacePolicy: savedPolicy }).where(eq(projects.id, issue.projectId!)); { - const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, inherited ? null : explicitPolicy, true); + const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, savedPolicy, true); const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: null, legacyUseProjectWorkspace: null }); input.policy.projectExecutionWorkspacePolicy = policy; input.policy.requestedExecutionWorkspaceMode = mode; @@ -840,7 +840,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace 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, mode: defaultMode, strategyType: "git_worktree", branchName: fresh ? null : inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" }); + expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, mode: defaultMode, strategyType: "git_worktree", branchName: fresh ? null : fromDefault ? "WSP-1-prepare-this-workspace" : "test-WSP-1" }); }); async function installDefault() { const [plugin] = await db.insert(plugins).values({ pluginKey: "test.default-storage", packageName: "test-storage", version: "1.0.0", status: "ready", diff --git a/server/src/services/plugin-lifecycle-delivery.ts b/server/src/services/plugin-lifecycle-delivery.ts index f11032a52c4..e70c45d0ff2 100644 --- a/server/src/services/plugin-lifecycle-delivery.ts +++ b/server/src/services/plugin-lifecycle-delivery.ts @@ -1,10 +1,12 @@ import { projectInstanceSecretService } from "./project-instance-secrets.js"; import { and, asc, eq, isNull, or, sql } from "drizzle-orm"; -import { pluginCompanySettings, pluginConfig, plugins, type Db } from "@paperclipai/db"; +import { pluginCompanySettings, pluginConfig, plugins, projects, type Db } from "@paperclipai/db"; import { logger } from "../middleware/logger.js"; import { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.js"; import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js"; +import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { isWorkspaceProvider } from "./project-workspace-provider.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; @@ -75,6 +77,16 @@ export function registerPluginLifecycleDelivery( .where(and(eq(plugins.id, plugin.id), eq(plugins.status, "ready"), or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true)))); if (!enabled?.manifest.resourceLifecycle?.includes(event.resourceType) || !enabled.manifest.capabilities.includes("events.subscribe")) break; + if (event.resourceType === "project" && isWorkspaceProvider(enabled.manifest)) { + stage = "ownership"; + const [project] = await db.select({ policy: projects.executionWorkspacePolicy }).from(projects) + .where(and(eq(projects.id, event.resourceId), eq(projects.companyId, companyId))); + if (project && parseProjectExecutionWorkspacePolicy(project.policy)?.storageProviderPluginId !== plugin.id) { + await inbox.acknowledge(companyId, event.id); + wake(); + continue; + } + } stage = "credentials"; const credentials = event.resourceType === "project" && enabled.manifest.capabilities.includes("projects.instance-secrets") ? await projectInstanceSecretService(db, plugin.id).prepare(event) : undefined; diff --git a/server/src/services/project-workspace-provider.ts b/server/src/services/project-workspace-provider.ts index cc8e8272b2c..bd216b5aebe 100644 --- a/server/src/services/project-workspace-provider.ts +++ b/server/src/services/project-workspace-provider.ts @@ -1,6 +1,6 @@ import { and, eq, notInArray } from "drizzle-orm"; import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db"; -import type { PaperclipPluginManifestV1, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared"; +import type { PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { conflict } from "../errors.js"; @@ -30,13 +30,9 @@ export function resolveProjectWorkspaceProvider( ): 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 pluginId = policy?.storageProviderPluginId ?? null; + const selected = pluginId ? providers.find(plugin => plugin.id === pluginId) : undefined; + let error: string | null = null; const declaration = selected?.manifestJson.workspaceProvider; const supplied = hasGitSource ? declaration?.defaultForProjects : undefined; const effectivePolicy = pluginId ? parseProjectExecutionWorkspacePolicy({ @@ -55,7 +51,7 @@ export function resolveProjectWorkspaceProvider( 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", + source: explicit ? "project" : "core", pluginId, displayName: selected?.manifestJson.displayName ?? (pluginId ? "Unavailable provider" : "Core / local"), providers: providers.map(plugin => ({ id: plugin.id, displayName: plugin.manifestJson.displayName, @@ -65,6 +61,24 @@ export function resolveProjectWorkspaceProvider( }; } +export function sourcelessProjectWorkspacePolicy(rawPolicy: unknown): ProjectExecutionWorkspacePolicy | null { + const policy = parseProjectExecutionWorkspacePolicy(rawPolicy); + if (policy?.storageProviderPluginId === undefined) return policy; + const { storageProviderPluginId: _storage, ...settings } = policy; + return Object.keys(settings).some(key => key !== "enabled") ? settings : null; +} + +export async function withDefaultProjectStorage(db: ProviderDb, companyId: string, rawPolicy: unknown) { + if (parseProjectExecutionWorkspacePolicy(rawPolicy)?.storageProviderPluginId !== undefined) return rawPolicy; + const defaults = (await listProjectWorkspaceProviders(db, companyId)) + .filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects); + if (defaults.length > 1) { + throw conflict(`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 provider.`); + } + if (!defaults[0]) return rawPolicy; + return { enabled: true, ...(rawPolicy as Record | null), storageProviderPluginId: defaults[0].id }; +} + 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); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 5af27d351a7..92e877d9bc1 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,6 +1,6 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js"; -import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js"; +import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, withDefaultProjectStorage } 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"; @@ -115,6 +115,7 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin visibility: "private", personalOwnerUserId: userId, privacyOwnerUserId: userId, + executionWorkspacePolicy: await withDefaultProjectStorage(dbOrTx, companyId, null) as Record | null, }) .onConflictDoNothing() .returning() @@ -673,12 +674,13 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool // skipped validation whenever `goalIds: []` and `goalId` arrive // together (goalIds wins resolution, mirroring the update path). const legacyGoalId = ids?.[0] ?? null; + const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null) as typeof projectData.executionWorkspacePolicy; return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const row = await tx .insert(projects) - .values({ ...projectData, goalId: legacyGoalId, companyId }) + .values({ ...projectData, executionWorkspacePolicy, goalId: legacyGoalId, companyId }) .returning() .then((rows) => rows[0]); if (ids && ids.length > 0) { @@ -979,12 +981,19 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool .where(eq(projects.id, id)).for("update") .then((rows) => rows[0] ?? null); if (!existingProject) return null; - if (projectData.executionWorkspacePolicy !== undefined && - projectData.executionWorkspacePolicy?.storageProviderPluginId !== existingProject.executionWorkspacePolicy?.storageProviderPluginId) { - 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 (projectData.executionWorkspacePolicy !== undefined) { + const attachedPluginId = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId; + const requestedPluginId = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId; + if (attachedPluginId) { + if (requestedPluginId !== undefined && requestedPluginId !== attachedPluginId) { + throw conflict("A plugin-backed project stays attached to its storage plugin"); + } + if (requestedPluginId === undefined) { + projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: attachedPluginId }; + } + } else if (requestedPluginId) { + 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 ec8c14cac2e..8035b3a5f79 100644 --- a/ui/src/components/ProjectProperties.concurrency.test.tsx +++ b/ui/src/components/ProjectProperties.concurrency.test.tsx @@ -142,22 +142,29 @@ it("hides project isolation controls without rewriting its policy", () => { describe("ProjectProperties — workspace storage selection", () => { const inherited = { - source: "default" as const, pluginId: "provider-one", displayName: "Managed storage", error: null, + source: "core" as const, pluginId: null, 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", () => { + it("shows an unattached project on Core storage and offers each plugin", () => { render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn()); - expect(storageSelect().value).toBe("inherit"); - expect(container.textContent).toContain("Inherited default: Managed storage"); + expect(storageSelect().value).toBe("core"); + expect(storageSelect().disabled).toBe(false); + expect(Array.from(storageSelect().options).map(option => option.value)).toEqual(["core", "provider-one", "provider-two"]); + expect(container.textContent).toContain("A project moved to a storage plugin stays on that plugin."); }); - it.each(["core", "provider-two", "inherit"])("saves the deliberate %s choice while preserving explicit settings", value => { + it("saves a move to a plugin while preserving explicit settings", () => { 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 }); + render(makeProject({ executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate); + act(() => { storageSelect().value = "provider-two"; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); }); + expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize", storageProviderPluginId: "provider-two" } }); + }); + it("locks the control on a plugin-backed project", () => { + render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" }, workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn()); + expect(storageSelect().value).toBe("provider-one"); + expect(storageSelect().disabled).toBe(true); + expect(container.textContent).toContain("This project stays on Managed storage."); }); it("does not copy inherited mode, strategy, or provider when another setting changes", () => { const onFieldUpdate = vi.fn(); @@ -165,9 +172,8 @@ describe("ProjectProperties — workspace storage selection", () => { 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); + it("shows a provider configuration error", () => { + render(makeProject({ workspaceProvider: { ...inherited, error: "The workspace storage plugin is unavailable" } }), vi.fn()); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("unavailable"); }); }); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index cdaffaa5d5e..8422033fb94 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -742,26 +742,26 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda id="workspace-storage-provider" aria-label="Workspace storage provider" className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" - disabled={!onUpdate && !onFieldUpdate} - value={storedExecutionWorkspacePolicy?.storageProviderPluginId === null ? "core" : storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "inherit"} + disabled={Boolean(storedExecutionWorkspacePolicy?.storageProviderPluginId) || (!onUpdate && !onFieldUpdate)} + value={storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "core"} onChange={(event) => { const policy = { enabled: true, ...storedExecutionWorkspacePolicy }; - if (event.target.value === "inherit") delete policy.storageProviderPluginId; - else policy.storageProviderPluginId = event.target.value === "core" ? null : event.target.value; + policy.storageProviderPluginId = event.target.value === "core" ? null : event.target.value; commitField("workspace_storage_provider", { executionWorkspacePolicy: policy }); }} > - {providerConfiguration.providers.map(provider => ( - + ))} {storedExecutionWorkspacePolicy?.storageProviderPluginId && !providerConfiguration.providers.some(provider => provider.id === storedExecutionWorkspacePolicy.storageProviderPluginId) ? ( ) : null}

- {providerConfiguration.source === "default" ? `Inherited default: ${providerConfiguration.displayName}` : `Selected: ${providerConfiguration.displayName}`} + {storedExecutionWorkspacePolicy?.storageProviderPluginId + ? `This project stays on ${providerConfiguration.displayName}.` + : "A project moved to a storage plugin stays on that plugin."}

{providerConfiguration.error ?

{providerConfiguration.error}

: null}