mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## 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 on5d42998466. All review threads are resolved. - Final GitHub CI is fully green on5d42998466. 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 on5d4299846after all fixes, with no unresolved threads - [x] Apex is 5/5 on5d42998466, 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>
208 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|