mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Preserve inherited workspace settings through task preparation
Keep unspecified task modes inherited during creation and import. Retain declared provider strategies for operator branches, and permit pinning or unpinning the same effective storage owner with active workspaces. Verified 58 targeted policy, database, and heartbeat regressions and the server TypeScript check. Full suites run in GitHub CI. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
5 files changed
+69
-8
No files matched your search
@@ -41,6 +41,15 @@ describe("execution workspace policy helpers", () => {
|
||||
}),
|
||||
).toEqual({ mode: "shared_workspace" });
|
||||
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
|
||||
expect(defaultIssueExecutionWorkspaceSettingsForProject({ enabled: true })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["shared_workspace", "operator_branch"] as const)("keeps declared provider strategy for %s", mode => {
|
||||
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, issueSettings: null, mode, legacyUseProjectWorkspace: null,
|
||||
projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001", defaultMode: mode,
|
||||
workspaceStrategy: { type: "git_worktree", baseRef: "release" } } });
|
||||
expect(config.workspaceStrategy).toEqual({ type: "git_worktree", baseRef: "release" });
|
||||
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree");
|
||||
});
|
||||
|
||||
it("prefers explicit issue mode over project policy and legacy overrides", () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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, pluginCompanySettings, plugins, projects } from "@paperclipai/db";
|
||||
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";
|
||||
@@ -52,6 +56,45 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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);
|
||||
|
||||
@@ -345,7 +345,8 @@ export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
): IssueExecutionWorkspaceSettings | null {
|
||||
if (!projectPolicy?.enabled) return null;
|
||||
// An unspecified mode must keep inheriting the effective provider default.
|
||||
if (!projectPolicy?.enabled || projectPolicy.defaultMode === undefined) return null;
|
||||
return {
|
||||
mode:
|
||||
projectPolicy.defaultMode === "isolated_workspace"
|
||||
@@ -450,7 +451,8 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
|
||||
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
|
||||
|
||||
if (hasWorkspaceControl) {
|
||||
if (input.mode === "isolated_workspace") {
|
||||
if (input.mode === "isolated_workspace" ||
|
||||
(projectHasPolicy && input.projectPolicy?.storageProviderPluginId && input.mode !== "agent_default")) {
|
||||
const projectStrategy = projectHasPolicy ? input.projectPolicy?.workspaceStrategy : undefined;
|
||||
const issueStrategy = input.issueSettings?.workspaceStrategy;
|
||||
// An issue that changes its branch still needs the project's setup hooks.
|
||||
@@ -459,7 +461,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
|
||||
? { ...projectStrategy, ...issueStrategy }
|
||||
: issueStrategy ?? projectStrategy ??
|
||||
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
|
||||
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
|
||||
({ type: input.mode === "isolated_workspace" ? "git_worktree" : "project_primary" } satisfies ExecutionWorkspaceStrategy);
|
||||
if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) {
|
||||
delete strategy.branchTemplate;
|
||||
}
|
||||
|
||||
@@ -96,11 +96,15 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
};
|
||||
return { input, source, issue, run, envOrchestrator };
|
||||
}
|
||||
it.each([false, true])("prepares managed storage before acquisition (inherited default: %s) without touching a Core checkout", async inherited => {
|
||||
it.each([
|
||||
{ inherited: false, mode: "isolated_workspace" as const },
|
||||
{ inherited: true, mode: "isolated_workspace" as const },
|
||||
{ inherited: true, mode: "operator_branch" as const },
|
||||
])("prepares managed storage before acquisition (inherited: $inherited, mode: $mode) without touching a Core checkout", async ({ inherited, mode: defaultMode }) => {
|
||||
const { input, source, issue } = await fixture(true);
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey: "test.managed-storage", packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"],
|
||||
manifestJson: { id: "test.managed-storage", apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"],
|
||||
workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: "isolated_workspace" as const, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
const explicitPolicy = { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const,
|
||||
workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } };
|
||||
if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: explicitPolicy }).where(eq(projects.id, issue.projectId!));
|
||||
@@ -135,7 +139,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
await expect(access(source)).rejects.toThrow();
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
|
||||
expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" });
|
||||
expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, mode: defaultMode, strategyType: "git_worktree", branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" });
|
||||
});
|
||||
async function installDefault() {
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey: "test.default-storage", packageName: "test-storage", version: "1.0.0", status: "ready",
|
||||
|
||||
@@ -979,7 +979,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
if (!existingProject) return null;
|
||||
if (projectData.executionWorkspacePolicy !== undefined &&
|
||||
projectData.executionWorkspacePolicy?.storageProviderPluginId !== existingProject.executionWorkspacePolicy?.storageProviderPluginId) {
|
||||
await retireRemovedPluginWorkspaceDependents(db, id);
|
||||
const providers = await listProjectWorkspaceProviders(db, existingProject.companyId);
|
||||
const previousOwner = resolveProjectWorkspaceProvider(existingProject.executionWorkspacePolicy, providers, false).pluginId;
|
||||
const nextOwner = resolveProjectWorkspaceProvider(projectData.executionWorkspacePolicy, providers, false).pluginId;
|
||||
if (previousOwner !== nextOwner) await retireRemovedPluginWorkspaceDependents(db, id);
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
|
||||
|
||||
Reference in new issue
Block a user