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>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-22 17:12:29 -07:00
1 parent 6d44b80a5e
commit 6204239cfc
24 files changed
+577 -61

No files matched your search

@@ -41,6 +41,23 @@ 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.each(["shared_workspace", "operator_branch"] as const)("keeps the primary-source fallback for %s providers without a project strategy", mode => {
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: { workspaceStrategy: { type: "git_worktree", baseRef: "agent-branch" } },
issueSettings: null, mode, legacyUseProjectWorkspace: null,
projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001" } });
expect(config.workspaceStrategy).toEqual({ type: "project_primary" });
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("project_primary");
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
@@ -0,0 +1,207 @@
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);
});
});
@@ -130,7 +130,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
})();
return {
enabled,
...(typeof parsed.storageProviderPluginId === "string" ? { storageProviderPluginId: parsed.storageProviderPluginId } : {}),
...(typeof parsed.storageProviderPluginId === "string" || parsed.storageProviderPluginId === null ? { storageProviderPluginId: parsed.storageProviderPluginId } : {}),
...(sharedWorkspaceConcurrency ? { sharedWorkspaceConcurrency } : {}),
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
@@ -345,7 +345,7 @@ export function resolveExecutionWorkspaceEnvironmentId(input: {
export function defaultIssueExecutionWorkspaceSettingsForProject(
projectPolicy: ProjectExecutionWorkspacePolicy | null,
): IssueExecutionWorkspaceSettings | null {
if (!projectPolicy?.enabled) return null;
if (!projectPolicy?.enabled || projectPolicy.defaultMode === undefined) return null;
return {
mode:
projectPolicy.defaultMode === "isolated_workspace"
@@ -450,7 +450,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.
@@ -458,8 +459,8 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
const strategy = issueStrategy && projectStrategy?.type === issueStrategy.type
? { ...projectStrategy, ...issueStrategy }
: issueStrategy ?? projectStrategy ??
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
(input.mode === "isolated_workspace" ? parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) : undefined) ??
({ type: input.mode === "isolated_workspace" ? "git_worktree" : "project_primary" } satisfies ExecutionWorkspaceStrategy);
if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) {
delete strategy.branchTemplate;
}
+16 -5
View File
@@ -1,3 +1,4 @@
import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js";
import { inspectPluginWorkspace, removePluginWorkspace, restorePluginWorkspace, type WorkspaceWorkers } from "./plugin-workspaces.js";
import { acquireExecutionWorkspaceLifecycleLock, withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifecycle-lock.js";
import { measureRunTransaction } from "./execution-transaction-timing.js";
@@ -1406,6 +1407,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
companyId: string; issueId?: string | null; assigneeAgentId?: string | null;
assigneeAdapterOverrides?: unknown; executionWorkspaceSettings?: unknown;
executionWorkspacePolicy: unknown; typedSelection?: boolean; projectId?: string | null; executionPolicy?: unknown;
sourceWorkspaceId?: string | null; inheritProviderDefaults?: boolean;
}, reader: Db | DbTransaction = db) {
const [task] = input.issueId ? await reader.select().from(issues).where(and(
eq(issues.id, input.issueId), eq(issues.companyId, input.companyId),
@@ -1415,12 +1417,21 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
.where(and(eq(agents.id, assigneeAgentId), eq(agents.companyId, input.companyId))) : [];
const overrides = assigneeAgentId ? parseIssueAssigneeAdapterOverrides(task ? task.assigneeAdapterOverrides : input.assigneeAdapterOverrides) : null;
const experimental = await instanceSettingsService(db).getExperimental();
const sourceId = input.sourceWorkspaceId ?? task?.projectWorkspaceId;
const sourceProjectId = input.projectId ?? task?.projectId;
const [source] = sourceId || sourceProjectId ? await reader.select({ repoUrl: projectWorkspaces.repoUrl }).from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, input.companyId),
sourceId ? eq(projectWorkspaces.id, sourceId) : eq(projectWorkspaces.projectId, sourceProjectId!),
)).orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)).limit(1) : [];
const effectivePolicy = input.inheritProviderDefaults === false
? parseProjectExecutionWorkspacePolicy(input.executionWorkspacePolicy)
: await effectiveProjectWorkspacePolicy(reader, input.companyId, input.executionWorkspacePolicy, Boolean(source?.repoUrl));
const projectPolicy = applyDefaultIsolatedExecutionWorkspacePolicy({
projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(input.executionWorkspacePolicy), experimental.enableIsolatedWorkspaces),
projectPolicy: gateProjectExecutionWorkspacePolicy(effectivePolicy, experimental.enableIsolatedWorkspaces || Boolean(effectivePolicy?.storageProviderPluginId)),
defaultIsolatedWorkspacesEnabled: experimental.enableIsolatedWorkspaces && experimental.enableIsolatedWorkspacesByDefault,
hasProjectWorkspace: true,
});
const settingsEnabled = experimental.enableIsolatedWorkspaces || input.typedSelection || task?.workspaceSelection || task?.executionWorkspaceId;
const settingsEnabled = experimental.enableIsolatedWorkspaces || Boolean(effectivePolicy?.storageProviderPluginId) || input.typedSelection || task?.workspaceSelection || task?.executionWorkspaceId;
const issueSettings = settingsEnabled ? parseIssueExecutionWorkspaceSettings(input.executionWorkspaceSettings) : null;
const projectId = task ? task.projectId : input.projectId;
const [organization] = projectId ? await reader.select({ companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
@@ -1477,7 +1488,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
executionWorkspaceSettings = { ...parseObject(task.settings), mode };
}
await resolveSourceWorkspaceStrategy({ ...input, typedSelection: true,
executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy }, reader);
executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy, sourceWorkspaceId: source.id }, reader);
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId);
return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null,
executionWorkspaceSettings };
@@ -1567,7 +1578,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.id, patch.projectWorkspaceId!),
));
const { strategy, config } = await resolveSourceWorkspaceConfiguration({ ...input, typedSelection: true,
executionWorkspacePolicy: source?.policy, executionWorkspaceSettings: patch.executionWorkspaceSettings }, tx);
executionWorkspacePolicy: source?.policy, executionWorkspaceSettings: patch.executionWorkspaceSettings, sourceWorkspaceId: patch.projectWorkspaceId }, tx);
if (strategy === "git_worktree") {
const workspaceStrategy = { ...parseObject(config.workspaceStrategy), type: "git_worktree",
existingBranch: null, branchTemplate: "retry-{{issue.id}}" };
@@ -1636,7 +1647,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
if (!sourceProject) throw notFound("Workspace source is unavailable or inaccessible");
await resolveSourceWorkspaceStrategy({ companyId: task.companyId, issueId: task.id,
executionWorkspaceSettings: task.executionWorkspaceSettings,
executionWorkspacePolicy: sourceProject.executionWorkspacePolicy }, tx);
executionWorkspacePolicy: sourceProject.executionWorkspacePolicy, sourceWorkspaceId: sourceId, inheritProviderDefaults: !workspace }, tx);
}
async function prepareTaskWorkspaceUpdate(input: {
+21 -18
View File
@@ -508,6 +508,7 @@ import {
import { resolveAndRetainRunTrustPreset } from "./run-trust-preset.js";
import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js";
@@ -2208,19 +2209,6 @@ export function heartbeatService(
const [taskWorkspaceIntentRow] = issueId && !externalProvider ? await db.select({ intent: issues.workspaceSelection }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) : [];
const freshWorkspace = await readFreshWorkspaceReadiness(db, run);
const runtimeWorkspaceSelectionEnabled = !externalProvider && (freshWorkspace?.mode === "fresh_isolated_workspace" || taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: isolatedWorkspacesEnabled, hasTypedSelection: Boolean(taskWorkspaceIntentRow?.intent), hasBinding: Boolean(issueContext?.executionWorkspaceId) }));
const parsedIssueExecutionWorkspaceSettings =
parseIssueExecutionWorkspaceSettings(
issueContext?.executionWorkspaceSettings,
);
const issueExecutionWorkspaceSettings = runtimeWorkspaceSelectionEnabled
? parsedIssueExecutionWorkspaceSettings
: null;
const environmentExecutionWorkspaceSettings =
selectEnvironmentExecutionWorkspaceSettings(
parsedIssueExecutionWorkspaceSettings,
runtimeWorkspaceSelectionEnabled,
);
const contextProjectId = readNonEmptyString(context.projectId);
const boundSourceWorkspace = !externalProvider && issueContext?.executionWorkspaceId
? await executionWorkspacesSvc.getStoredById(agent.companyId, issueContext.executionWorkspaceId) : null;
@@ -2242,6 +2230,9 @@ export function heartbeatService(
or nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null)`,
)),
).mapWith(Boolean),
hasGitSource: exists(db.select({ id: projectWorkspaces.id }).from(projectWorkspaces)
.where(and(eq(projectWorkspaces.projectId, projects.id), eq(projectWorkspaces.companyId, agent.companyId),
sql`nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null`))).mapWith(Boolean),
env: projects.env,
updatedAt: projects.updatedAt,
})
@@ -2377,21 +2368,33 @@ export function heartbeatService(
!boundSourceWorkspace && !taskWorkspaceIntentRow?.intent && !issueContext?.projectWorkspaceId
? taskProjectContext : null
);
const parsedProjectExecutionWorkspacePolicy =
parseProjectExecutionWorkspacePolicy(
workspacePolicyContext?.executionWorkspacePolicy,
);
const parsedProjectExecutionWorkspacePolicy = workspacePolicyContext && !boundSourceWorkspace
? await effectiveProjectWorkspacePolicy(db, agent.companyId, workspacePolicyContext.executionWorkspacePolicy, workspacePolicyContext.hasGitSource)
: parseProjectExecutionWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy);
const projectExecutionWorkspacePolicy =
applyDefaultIsolatedExecutionWorkspacePolicy({
projectPolicy: gateProjectExecutionWorkspacePolicy(
parsedProjectExecutionWorkspacePolicy,
isolatedWorkspacesEnabled,
isolatedWorkspacesEnabled || Boolean(parsedProjectExecutionWorkspacePolicy?.storageProviderPluginId),
),
defaultIsolatedWorkspacesEnabled,
// Projects without workspace configuration get a plain managed
// directory. The operator default cannot turn it into a worktree.
hasProjectWorkspace: workspaceProjectContext?.hasWorkspace ?? false,
});
const runtimeWorkspaceSelectionEnabled = !externalProvider && (freshWorkspace?.mode === "fresh_isolated_workspace" || taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: isolatedWorkspacesEnabled || Boolean(projectExecutionWorkspacePolicy?.storageProviderPluginId), hasTypedSelection: Boolean(taskWorkspaceIntentRow?.intent), hasBinding: Boolean(issueContext?.executionWorkspaceId) }));
const parsedIssueExecutionWorkspaceSettings =
parseIssueExecutionWorkspaceSettings(
issueContext?.executionWorkspaceSettings,
);
const issueExecutionWorkspaceSettings = runtimeWorkspaceSelectionEnabled
? parsedIssueExecutionWorkspaceSettings
: null;
const environmentExecutionWorkspaceSettings =
selectEnvironmentExecutionWorkspaceSettings(
parsedIssueExecutionWorkspaceSettings,
runtimeWorkspaceSelectionEnabled,
);
const retainedTrust = await resolveAndRetainRunTrustPreset(db, {
companyId: agent.companyId,
agentId: agent.id,
@@ -25,7 +25,8 @@ import { isolatedRemoteSourcePath } from "../isolated-remote-workspace-source.js
import { cleanupExecutionWorkspaceArtifacts } from "../workspace-runtime.js";
import { buildProjectMentionHref } from "@paperclipai/shared";
import { trace } from "@opentelemetry/api";
import { buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js";
import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js";
import { resolveExecutionWorkspaceMode, buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js";
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
import { createStorageService } from "../../storage/service.js";
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
@@ -53,7 +54,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
});
beforeEach(async () => {
vi.restoreAllMocks();
await db.execute(sql`truncate table companies restart identity cascade`);
await db.execute(sql`truncate table companies, plugins restart identity cascade`);
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
});
@@ -95,12 +96,26 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
};
return { input, source, issue, run, envOrchestrator };
}
it("prepares managed storage before acquisition without touching a Core checkout", async () => {
it.each([
{ 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"] }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: plugin.id } }).where(eq(projects.id, issue.projectId!));
workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: 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!));
{
const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, inherited ? null : explicitPolicy, true);
const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: null, legacyUseProjectWorkspace: null });
input.policy.projectExecutionWorkspacePolicy = policy;
input.policy.requestedExecutionWorkspaceMode = mode;
input.policy.effectiveExecutionWorkspaceMode = mode;
input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: input.config.mergedConfig, projectPolicy: policy, issueSettings: null, mode, legacyUseProjectWorkspace: null });
}
await db.update(projectWorkspaces).set({ cwd: null, sourceType: "remote_managed", repoUrl: "https://github.com/example/repo.git" }).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!));
await rm(source, { recursive: true });
const sequence: string[] = [];
@@ -124,8 +139,41 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
await expect(access(source)).rejects.toThrow();
const [task] = await db.select().from(issues).where(eq(issues.id, issue.id));
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, branchName: "test-WSP-1" });
expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, 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",
manifestJson: { id: "test.default-storage", apiVersion: 1, version: "1.0.0", displayName: "Default storage", description: "Storage", author: "Test", categories: ["workspace"],
workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"], defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
return plugin;
}
it("keeps source-free project tasks outside installed provider preparation", async () => {
const { input, issue } = await fixture();
await installDefault();
await db.update(issues).set({ projectWorkspaceId: null }).where(eq(issues.id, issue.id));
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!));
input.task.issueRef = { ...issue, projectWorkspaceId: null };
input.policy.projectExecutionWorkspacePolicy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, false);
const call = vi.fn();
input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any;
const prepared = await prepareHeartbeatWorkspace(db, input);
expect(prepared.executionWorkspace.cwd).toBeTruthy();
expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin");
expect(call).not.toHaveBeenCalled();
});
it("pins a resolved Core choice through source selection when a default appears during setup", async () => {
const { input, source } = await fixture();
expect(input.policy.projectExecutionWorkspacePolicy).toBeNull();
await installDefault();
const call = vi.fn();
input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any;
const prepared = await prepareHeartbeatWorkspace(db, input);
expect(prepared.executionWorkspace.cwd).toBe(source);
expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin");
expect(call).not.toHaveBeenCalled();
});
const readIssue = (id: string) => db.select().from(issues).where(eq(issues.id, id)).then(rows => rows[0]!);
const readRun = (id: string) => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)).then(rows => rows[0]!);
const leases = (id: string) => db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, id));
@@ -135,6 +183,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
await db.update(projects).set({ executionWorkspacePolicy: {
enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
} }).where(eq(projects.id, issue.projectId!));
input.policy.projectExecutionWorkspacePolicy = { enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" };
input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id,
sourceRunId: input.run.id, retainedSourceIds: [] };
const call = vi.fn();
@@ -27,7 +27,7 @@ import path from "node:path";
import { CONTROLLER_PROVISION_COPY_ONLY, workspaceRequiresCopyOnlyProvisioning } from "../workspace-host-provision.js";
import { and, asc, eq } from "drizzle-orm";
import { type ExecutionWorkspace } from "@paperclipai/shared";
import { heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
import { heartbeatRuns, issues, projectWorkspaces } from "@paperclipai/db";
import { getStartupTracer } from "../../instrumentation.js";
import { createHostDuplexObservabilityRecorder } from "../duplex-observability-recorder.js";
import { incrementToolRuntimeMetricCounter } from "../tool-runtime-metrics.js";
@@ -330,6 +330,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
useProjectWorkspace:
requestedExecutionWorkspaceMode !== "agent_default",
configuredCwd: readNonEmptyString(mergedConfig.cwd),
storageProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null,
anchorWorkspace: !pluginTaskWorkspace && requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
? await resolveReusedGitWorkspaceAnchor({
agent,
@@ -375,14 +376,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}));
const hostProvisionDriver = selectedEnvironmentForConfig?.driver ?? lowTrustPreflightEnvironmentDriver;
if (fresh && !isDotRun) {
const [project] = resolvedWorkspace.projectId ? await db.select({
policy: projects.executionWorkspacePolicy,
}).from(projects).where(and(eq(projects.id, resolvedWorkspace.projectId),
eq(projects.companyId, agent.companyId))) : [];
const sourceWorkspace = reusableExistingExecutionWorkspace ?? existingExecutionWorkspace;
if (sourceWorkspace?.providerType === "plugin" ||
readWorkspaceProviderBinding(sourceWorkspace?.metadata) ||
project?.policy?.storageProviderPluginId) {
(resolvedWorkspace.workspaceId && projectExecutionWorkspacePolicy?.storageProviderPluginId)) {
throw new WorkspaceValidationFailure("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", {
reason: "fresh_workspace_storage_unsupported",
});
@@ -392,6 +389,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
companyId: agent.companyId, projectId: resolvedWorkspace.projectId, projectWorkspaceId: resolvedWorkspace.workspaceId,
issueId, runId: run.id, agentId: agent.id, responsibleUserId,
existingWorkspaceId: reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null,
mode: requestedExecutionWorkspaceMode,
strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig),
branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create",
+7 -2
View File
@@ -1,3 +1,4 @@
import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js";
import { canonicalRepositoryRoot } from "../execution-workspace-source-roots.js";
import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js";
import { selectRestoredProjectRepositories, type GitWorkspaceContents } from "../git-workspace-contents.js";
@@ -3406,7 +3407,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null },
opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null },
): Promise<ResolvedAnchorWorkspaceForRun> {
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
@@ -3508,8 +3509,11 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
}
const [sourceProject] = projectWorkspaceRows.length ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects)
.where(and(eq(projects.id, workspaceProjectId!), eq(projects.companyId, agent.companyId))) : [];
const storageProviderPluginId = opts?.storageProviderPluginId !== undefined ? opts.storageProviderPluginId : sourceProject?.policy?.storageProviderPluginId ??
(sourceProject ? (await effectiveProjectWorkspacePolicy(db, agent.companyId, sourceProject.policy,
projectWorkspaceRows.some(workspace => Boolean(workspace.repoUrl))))?.storageProviderPluginId : null);
for (const workspace of projectWorkspaceRows) {
if (sourceProject?.policy?.storageProviderPluginId) {
if (storageProviderPluginId) {
return { cwd: `/workspaces/${workspace.id}`, source: "project_primary" as const,
projectId: workspace.projectId, workspaceId: workspace.id, repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef, workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [] };
@@ -3676,6 +3680,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
useProjectWorkspace?: boolean | null;
configuredCwd?: string | null;
executionEnvironmentDriver?: string | null;
storageProviderPluginId?: string | null;
anchorWorkspace?: ResolvedAnchorWorkspaceForRun;
},
): Promise<ResolvedWorkspaceForRun> {
+2 -2
View File
@@ -10235,7 +10235,7 @@ export function issueService(db: Db) {
if (projectWorkspaceId && isolatedWorkspacesEnabled) {
const sourceStrategy = await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId,
assigneeAgentId: issueData.assigneeAgentId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides,
executionWorkspaceSettings, executionWorkspacePolicy: projectPolicy,
executionWorkspaceSettings, executionWorkspacePolicy: projectPolicy, sourceWorkspaceId: projectWorkspaceId,
typedSelection: false, projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx);
if (sourceStrategy === "git_worktree") workspaceIntent = { version: 1, source: "project",
selection: { kind: "configured_source", projectWorkspaceId, mode: "managed_isolated" } };
@@ -10248,7 +10248,7 @@ export function issueService(db: Db) {
if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible");
await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId,
assigneeAgentId: issueData.assigneeAgentId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides,
executionWorkspaceSettings, executionWorkspacePolicy: readableSource.executionWorkspacePolicy,
executionWorkspaceSettings, executionWorkspacePolicy: readableSource.executionWorkspacePolicy, sourceWorkspaceId: projectWorkspaceId,
typedSelection: Boolean(workspaceIntent), projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx);
await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId);
}
+9 -8
View File
@@ -1,3 +1,4 @@
import { effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js";
import { randomUUID, createHash } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import {
@@ -56,9 +57,7 @@ async function provider(
!plugin ||
plugin.status !== "ready" ||
settings?.enabled === false ||
!plugin.manifestJson.workspaceProvider ||
!plugin.manifestJson.capabilities.includes("workspaces.manage") ||
!plugin.manifestJson.resourceLifecycle?.includes("project") ||
!isWorkspaceProvider(plugin.manifestJson) ||
!workers
.getWorker(pluginId)
?.supportedMethods?.includes("workspaceOperation")
@@ -164,6 +163,7 @@ export async function preparePluginWorkspace(
existingWorkspaceId: string | null;
mode: string;
strategy: string;
selectedProviderPluginId?: string | null;
branchMode?: "create" | "existing";
branchName?: string | null;
baseRef?: string | null;
@@ -185,6 +185,7 @@ export async function preparePluginWorkspace(
existing?.metadata?.workspaceProviderRemoval
)
throw conflict("Workspace storage is retired or awaiting cleanup");
if (!existing && (!input.projectId || !input.projectWorkspaceId)) return null;
const saved = readWorkspaceProviderBinding(existing?.metadata);
const mode = existing?.mode ?? input.mode,
strategy = existing?.strategyType ?? input.strategy;
@@ -200,10 +201,10 @@ export async function preparePluginWorkspace(
),
)
: [];
const owner =
saved?.pluginId ??
existing?.metadata?.workspaceProviderOwner ??
project?.executionWorkspacePolicy?.storageProviderPluginId;
if (existing && existing.providerType !== "plugin" && !saved && !existing.metadata?.workspaceProviderOwner) return null;
const owner = saved?.pluginId ?? existing?.metadata?.workspaceProviderOwner ??
(!existing && input.selectedProviderPluginId !== undefined ? input.selectedProviderPluginId :
!existing && project ? (await effectiveProjectWorkspacePolicy(db, input.companyId, project.executionWorkspacePolicy, false))?.storageProviderPluginId : null);
const pluginId = typeof owner === "string" ? owner : null;
if (!pluginId) {
if (existing?.providerType === "plugin")
@@ -341,7 +342,7 @@ export async function preparePluginWorkspace(
if (
!currentProject ||
currentProject.archivedAt ||
currentProject.executionWorkspacePolicy?.storageProviderPluginId !==
(await effectiveProjectWorkspacePolicy(tx, input.companyId, currentProject.executionWorkspacePolicy, false))?.storageProviderPluginId !==
pluginId ||
!currentSource ||
currentSource.repoUrl !== source.repoUrl ||
@@ -0,0 +1,72 @@
import { and, eq, notInArray } from "drizzle-orm";
import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db";
import type { PaperclipPluginManifestV1, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { conflict } from "../errors.js";
export function isWorkspaceProvider(manifest: PaperclipPluginManifestV1) {
return Boolean(manifest.workspaceProvider && manifest.capabilities.includes("workspaces.manage") &&
manifest.resourceLifecycle?.includes("project"));
}
type ProviderDb = Pick<Db, "select">;
export async function listProjectWorkspaceProviders(db: ProviderDb, companyId: string) {
const rows = await db.select({ plugin: plugins, enabled: pluginCompanySettings.enabled })
.from(plugins)
.leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.pluginId, plugins.id),
eq(pluginCompanySettings.companyId, companyId)))
.where(notInArray(plugins.status, ["disabled", "uninstalled"]));
return rows.filter(row => row.enabled !== false && isWorkspaceProvider(row.plugin.manifestJson))
.map(row => row.plugin);
}
type Provider = Awaited<ReturnType<typeof listProjectWorkspaceProviders>>[number];
export function resolveProjectWorkspaceProvider(
rawPolicy: unknown,
providers: Provider[],
hasGitSource: boolean,
): ProjectWorkspaceProviderConfiguration {
const policy = parseProjectExecutionWorkspacePolicy(rawPolicy);
const explicit = policy?.storageProviderPluginId !== undefined;
const defaults = providers.filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects);
const selected = explicit ? providers.find(plugin => plugin.id === policy?.storageProviderPluginId)
: defaults.length === 1 ? defaults[0] : undefined;
const pluginId = explicit ? policy?.storageProviderPluginId ?? null : selected?.id ?? null;
let error = !explicit && defaults.length > 1
? `Multiple workspace storage plugins claim the project default: ${defaults.map(plugin => plugin.manifestJson.displayName).sort().join(", ")}. Select a storage provider in project configuration or disable a conflicting default.`
: null;
const declaration = selected?.manifestJson.workspaceProvider;
const supplied = hasGitSource ? declaration?.defaultForProjects : undefined;
const effectivePolicy = pluginId ? parseProjectExecutionWorkspacePolicy({
enabled: true,
...(supplied ? { defaultMode: supplied.mode } : {}),
...policy,
storageProviderPluginId: pluginId,
...(supplied ? { workspaceStrategy: { type: supplied.strategy, ...policy?.workspaceStrategy } } : {}),
}) : policy;
if (pluginId && !selected) error = "The workspace storage plugin is unavailable";
if (declaration && effectivePolicy && hasGitSource && (
effectivePolicy.enabled === false ||
(effectivePolicy.defaultMode && !declaration.modes.includes(effectivePolicy.defaultMode as "isolated_workspace")) ||
(effectivePolicy.workspaceStrategy?.type && !declaration.strategies.includes(effectivePolicy.workspaceStrategy.type as "git_worktree"))
)) {
error = "The storage plugin does not support the explicit project workspace settings. Change the workspace mode or strategy, or select another storage provider in project configuration.";
}
return {
source: explicit ? "project" : selected || error ? "default" : "core",
pluginId,
displayName: selected?.manifestJson.displayName ?? (pluginId ? "Unavailable provider" : "Core / local"),
providers: providers.map(plugin => ({ id: plugin.id, displayName: plugin.manifestJson.displayName,
isDefault: Boolean(plugin.manifestJson.workspaceProvider?.defaultForProjects) })),
effectivePolicy,
error,
};
}
export async function effectiveProjectWorkspacePolicy(db: ProviderDb, companyId: string, rawPolicy: unknown, hasGitSource: boolean) {
const configuration = resolveProjectWorkspaceProvider(rawPolicy, await listProjectWorkspaceProviders(db, companyId), hasGitSource);
if (configuration.error) throw conflict(configuration.error);
return configuration.effectivePolicy;
}
+9 -1
View File
@@ -1,3 +1,4 @@
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { enqueueProjectSkillSource, detachProjectSkillSource } from "./project-skill-source-bindings.js";
@@ -138,6 +139,7 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
goalIds: string[];
goals: ProjectGoalRef[];
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaceProvider?: import("@paperclipai/shared").ProjectWorkspaceProviderConfiguration;
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
@@ -311,6 +313,7 @@ function pickPrimaryWorkspace(
async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<ProjectWithGoals[]> {
if (rows.length === 0) return [];
const providerCatalog = await listProjectWorkspaceProviders(db, rows[0]!.companyId);
const projectIds = rows.map((r) => r.id);
const workspaceRows = await db
.select()
@@ -393,6 +396,8 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
workspaces,
primaryWorkspace,
managedByPlugin: managedByProjectId.get(row.id) ?? null,
workspaceProvider: resolveProjectWorkspaceProvider(row.executionWorkspacePolicy, providerCatalog,
workspaces.some(workspace => Boolean(workspace.repoUrl))),
};
});
}
@@ -974,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);