Files
PaperClipAI/server/src/__tests__/project-workspace-provider.integration.test.ts
T
Nicky LeachandPaperclip 6204239cfc Support installed default project workspace providers (#17247)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Storage plugins already own project storage and task checkout
preparation.
> - Core only selected these plugins through an explicit project policy.
> - Installation could therefore leave new and existing projects on
incompatible Core preparation.
> - This pull request adds one generic manifest declaration and one
shared effective-policy resolver.
> - Project configuration shows inheritance while durable workspace
bindings retain their owners.

## Linked Issues or Issue Description

**Subsystem affected**

Plugin contracts, project configuration, and execution workspace
preparation.

**Problem or motivation**

An installed storage provider cannot become the company default.
Projects without an explicit provider keep Core preparation, even when
their execution environment cannot use a Core Git checkout.

**Proposed solution**

A provider declares supported `workspaceProvider.defaultForProjects`
mode and strategy. Company-enabled installations supply inherited
defaults. Explicit plugin selections and explicit Core/local
(`storageProviderPluginId: null`) win. Conflicting defaults require an
operator choice. The declaration and implementation contain no
provider-specific identifiers.

**Alternatives considered**

Rewriting every project would lose the distinction between inheritance
and overrides. Selecting by worker health would silently change storage
on an outage.

**Roadmap alignment**

Extends the existing workspace and sandbox provider contracts. The
roadmap has no duplicate default-provider implementation. Related public
work includes #17243, which covers prepared execution directories rather
than provider selection.

## What Changed

- Add a validated generic default-provider declaration and document it
in the SDK.
- Resolve provider selection and Git defaults consistently in project
responses, source selection, heartbeat source resolution, reservation,
and preparation.
- Keep stored policy separate from effective configuration. Add the
normal project configuration selector for inheritance, explicit plugins,
and Core/local.
- Preserve existing local and plugin workspace ownership. Pin selection
through preparation and keep provider failures visible.
- Add database, API, manifest, heartbeat, and UI regressions. Update the
verification maps.

## Verification

- Targeted Vitest runs cover manifest validation, project defaults and
API access, explicit overrides, existing ownership, new/imported task
inheritance, heartbeat routing, source-free tasks, selection pinning
during setup, and UI configuration.
- Combined policy, provider integration, and heartbeat run: 58 tests
pass; 60 unrelated cases excluded. After the final shared-provider
fallback fix, all 47 policy tests pass, including shared and
operator-mode regressions.
- Server and UI TypeScript checks, SDK build, verification-map
validation, credential-pattern scan, added-comment scan, and `git diff
--check` pass.
- A downstream integration uses this Core resolver and preparation
service with a real storage hook and mocked remote infrastructure. A new
Git project reaches the hook without a stored provider ID.
- Standard Greptile and Apex both scored 5/5 on
5d42998466. All review threads are
resolved.
- Final GitHub CI is fully green on
5d42998466. Browser shard 3 initially hit
a PostgreSQL deadlock during cleanup cancellation, which left an
experiment flag enabled for a later test. A failed-job retry passed all
26 browser tests. The workflow completed successfully on attempt 2. Full
final verification was delegated to CI at the user's request.
- No live provider or hosted tenant run was performed.

## Risks

- Installing one default-claiming provider changes effective selection
for unconfigured projects. Existing explicit settings and workspace
ownership remain intact; incompatible explicit settings fail clearly.
- Each configuration/admission resolution reads installed plugin
metadata and the selected company's enablement. Project list reads share
one catalog query. Selection does not call workers or external
providers.
- Deploy the Core contract before any downstream manifest uses the
declaration. No migration or backfill is required. A downstream
immutable image pin must include this change before packaging its
declaration.
- No merge or deployment is authorized by this PR.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The exact served model ID and context-window size are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run targeted tests locally and they pass; full gates are
delegated to CI at the user's request
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green on
5d42998466ad42400564ba34f48124f40c53fbc5; the branch has no merge
conflicts
- [x] Standard Greptile is 5/5 on 5d4299846 after all fixes, with no
unresolved threads
- [x] Apex is 5/5 on 5d42998466, after
standard Greptile 5/5 on that same head
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-22 17:12:29 -07:00

208 lines
15 KiB
TypeScript

import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import { companies, createDb, executionWorkspaces, heartbeatRuns, issues, pluginCompanySettings, plugins, projects } from "@paperclipai/db";
import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { effectiveProjectWorkspacePolicy, listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "../services/project-workspace-provider.js";
import { preparePluginWorkspace } from "../services/plugin-workspaces.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { issueService } from "../services/issues.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { buildExecutionWorkspaceAdapterConfig, parseIssueExecutionWorkspaceSettings, resolveEffectiveWorkspaceStrategyType, resolveExecutionWorkspaceMode } from "../services/execution-workspace-policy.js";
import { projectService } from "../services/projects.js";
import { projectRoutes } from "../routes/projects.js";
import { errorHandler } from "../middleware/index.js";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("installed project workspace defaults", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("project-provider-defaults");
db = createDb(database.connectionString);
}, 120_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
beforeEach(async () => { await db.execute(sql`truncate companies, plugins cascade`); });
async function fixture() {
const f = await createPluginWorkspaceFixture(db);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId));
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
} } }).where(eq(plugins.id, f.pluginId));
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
return f;
}
it("resolves new and existing unconfigured projects without persisting the inherited choice", async () => {
const f = await fixture();
const svc = projectService(db);
const created = await svc.create(f.companyId, { name: "New project" });
for (const projectId of [created!.id, f.input.projectId]) {
const project = await svc.getById(projectId);
expect(project!.workspaceProvider).toMatchObject({ source: "default", pluginId: f.pluginId });
expect(project!.executionWorkspacePolicy).toBeNull();
}
expect((await svc.getById(f.input.projectId))!.workspaceProvider!.effectivePolicy).toMatchObject({
enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree" }, storageProviderPluginId: f.pluginId,
});
expect(await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId: f.companyId,
projectId: f.input.projectId, sourceWorkspaceId: f.input.projectWorkspaceId, executionWorkspacePolicy: null })).toBe("git_worktree");
const prepared = await preparePluginWorkspace(db, f.workers, f.input);
expect(prepared).toMatchObject({ providerType: "plugin" });
expect(f.calls[0].operation).toMatchObject({ mode: "isolated_workspace", strategy: "git_worktree" });
});
it.each(["create", "import"] as const)("%s leaves an unspecified task mode inherited from the effective provider", async method => {
const f = await fixture();
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true } }).where(eq(projects.id, f.input.projectId));
let issueId: string;
if (method === "create") {
const created = await issueService(db).create(f.companyId, { title: "Use inherited storage", projectId: f.input.projectId, assigneeAgentId: f.input.agentId });
issueId = created.id;
} else {
issueId = randomUUID();
await issueService(db).importIssues(f.companyId, [{ id: issueId, ref: "inherited", title: "Import inherited storage", projectId: f.input.projectId,
projectWorkspaceId: null, description: null, assigneeAgentId: f.input.agentId, status: "todo", priority: "medium", billingCode: null,
assigneeAdapterOverrides: null, executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null }]);
}
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(task.executionWorkspaceSettings?.mode).toBeUndefined();
const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true }, true);
const settings = parseIssueExecutionWorkspaceSettings(task.executionWorkspaceSettings);
const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: settings, legacyUseProjectWorkspace: null });
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: policy, issueSettings: settings, mode, legacyUseProjectWorkspace: null });
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.input.agentId,
issueId, scopeKind: "issue", status: "running", runtimeMode: "legacy" }).returning();
await preparePluginWorkspace(db, f.workers, { ...f.input, runId: run.id, issueId, mode, strategy: resolveEffectiveWorkspaceStrategyType(mode, config) });
expect(f.calls[0].operation).toMatchObject({ mode: "isolated_workspace", strategy: "git_worktree" });
});
it("pins and unpins the same effective owner without retiring its active workspace", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
const svc = projectService(db);
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))!.workspaceProvider)
.toMatchObject({ source: "project", pluginId: f.pluginId });
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true } }))!.workspaceProvider)
.toMatchObject({ source: "default", pluginId: f.pluginId });
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace);
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }))
.rejects.toThrow("Retire managed execution workspaces");
});
it("preserves explicit alternatives and the explicit Core/local choice", async () => {
const f = await fixture(), other = await createPluginWorkspaceFixture(db, f.companyId);
const providers = await listProjectWorkspaceProviders(db, f.companyId);
expect(resolveProjectWorkspaceProvider({ enabled: true, storageProviderPluginId: other.pluginId }, providers, true))
.toMatchObject({ source: "project", pluginId: other.pluginId });
const local = { enabled: true, storageProviderPluginId: null };
await projectService(db).update(f.input.projectId, { executionWorkspacePolicy: local });
expect((await projectService(db).getById(f.input.projectId))!.executionWorkspacePolicy).toEqual(local);
expect(await preparePluginWorkspace(db, f.workers, f.input)).toBeNull();
expect(f.calls).toHaveLength(0);
});
it.each(["disabled", "uninstalled"] as const)("does not inherit an instance %s provider", async status => {
const f = await fixture();
await db.update(plugins).set({ status }).where(eq(plugins.id, f.pluginId));
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toBeNull();
});
it("scopes company enablement and does not borrow another company's override", async () => {
const f = await fixture();
const [other] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTH" }).returning();
await db.insert(pluginCompanySettings).values({ companyId: f.companyId, pluginId: f.pluginId, enabled: false });
await db.insert(pluginCompanySettings).values({ companyId: other.id, pluginId: f.pluginId, enabled: true });
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toBeNull();
expect(await effectiveProjectWorkspacePolicy(db, other.id, null, true)).toMatchObject({ storageProviderPluginId: f.pluginId });
await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true))
.rejects.toThrow("unavailable");
});
it.each(["error", "installed", "upgrade_pending", "ready"] as const)("retains the selected %s provider when its worker is unavailable", async status => {
const f = await fixture();
await db.update(plugins).set({ status }).where(eq(plugins.id, f.pluginId));
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toMatchObject({ storageProviderPluginId: f.pluginId });
await expect(preparePluginWorkspace(db, undefined, f.input)).rejects.toThrow("unavailable");
expect(await db.select().from(executionWorkspaces)).toHaveLength(0);
});
it("never falls back when a selected default is disabled during preparation", async () => {
const f = await fixture();
const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, null, true);
await db.insert(pluginCompanySettings).values({ companyId: f.companyId, pluginId: f.pluginId, enabled: false });
await expect(preparePluginWorkspace(db, f.workers, { ...f.input, selectedProviderPluginId: policy!.storageProviderPluginId }))
.rejects.toThrow("unavailable");
expect(await db.select().from(executionWorkspaces)).toHaveLength(0);
});
it("reports conflicting declarations and still lets configuration select an alternative", async () => {
const f = await fixture();
const other = await createPluginWorkspaceFixture(db, f.companyId);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId));
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId));
await expect(preparePluginWorkspace(db, f.workers, f.input)).rejects.toThrow("Multiple workspace storage plugins");
const project = await projectService(db).getById(f.input.projectId);
expect(project!.workspaceProvider!.error).toContain("Select a storage provider in project configuration");
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true))
.toMatchObject({ storageProviderPluginId: f.pluginId });
});
it.each([
{ enabled: false },
{ enabled: true, defaultMode: "shared_workspace" },
{ enabled: true, workspaceStrategy: { type: "project_primary" } },
])("rejects incompatible explicit settings: %j", async policy => {
const f = await fixture();
await expect(effectiveProjectWorkspacePolicy(db, f.companyId, policy, true)).rejects.toThrow("explicit project workspace settings");
});
it("preserves compatible explicit fields while supplying only missing defaults", async () => {
const f = await fixture();
const policy = { enabled: true, defaultMode: "operator_branch", workspaceStrategy: { type: "git_worktree", baseRef: "release", branchTemplate: "kept" } };
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, policy, true)).toEqual({ ...policy, storageProviderPluginId: f.pluginId });
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, false)).toEqual({ enabled: true, storageProviderPluginId: f.pluginId });
});
it("retains both plugin and local workspace ownership when defaults change", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }).where(eq(projects.id, f.input.projectId));
expect(await preparePluginWorkspace(db, f.workers, { ...f.input, existingWorkspaceId: workspace!.id })).toMatchObject({ id: workspace!.id });
await db.update(plugins).set({ status: "error" }).where(eq(plugins.id, f.pluginId));
await expect(preparePluginWorkspace(db, f.workers, { ...f.input, existingWorkspaceId: workspace!.id })).rejects.toThrow("unavailable");
const [local] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.input.projectId,
projectWorkspaceId: f.input.projectWorkspaceId, mode: "shared_workspace", strategyType: "project_primary", name: "Retained local", providerType: "local_fs", cwd: "/fixture/kept" }).returning();
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
expect(await preparePluginWorkspace(db, f.workers, { ...f.input, existingWorkspaceId: local.id })).toBeNull();
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, local.id)))[0]).toEqual(local);
});
it("exposes inherited selection through the normal API and round-trips overrides with company access checks", async () => {
const f = await fixture();
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db));
app.use(errorHandler);
const url = `/api/projects/${f.input.projectId}`;
expect((await request(app).get(url)).body.workspaceProvider).toMatchObject({ source: "default", pluginId: f.pluginId });
const patch = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } });
expect(patch.status).toBe(200);
expect(patch.body.workspaceProvider).toMatchObject({ source: "project", pluginId: null });
const inherit = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true } });
expect(inherit.status).toBe(200);
expect(inherit.body.executionWorkspacePolicy).not.toHaveProperty("storageProviderPluginId");
expect(inherit.body.workspaceProvider).toMatchObject({ source: "default", pluginId: f.pluginId });
const denied = express();
denied.use((req, _res, next) => { req.actor = { type: "agent", agentId: f.input.agentId, companyId: "00000000-0000-4000-8000-000000000099" }; next(); });
denied.use("/api", projectRoutes(db)); denied.use(errorHandler);
expect((await request(denied).get(url)).status).toBe(404);
});
});