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

+6
View File
@@ -148,3 +148,9 @@ Evidence: `source-only` — reviewed at `9ef434fe79c57d976b8b03b14c5242db270b832
Verification: `not-run` — Verify allowed creation and reads as the actual operator/agent, then compare project and task access after adding/removing a person. Reject ordinary members’ audience changes, agent member/visibility mutations, foreign-company targets, and owner removal. After opening the project, inspect protected tasks and independent direct grants rather than inferring task publication from project discovery.
Gaps: The dedicated create_project tool does not advertise a visibility field; explicit private selection uses REST or admitted call_api. Private-project creation through each client, inherited task audiences, and responsible-user combinations need separate live evidence. The map does not infer filesystem isolation from these app-record permissions.
Project configuration responses also expose effective workspace storage through
`workspaceProvider`, separate from the stored execution policy. Existing project
read/update permissions apply. Omitted provider IDs inherit; null explicitly selects
Core/local. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts)
checks overrides and cross-company denial; live agent credential verification remains unqualified.
+4
View File
@@ -29,6 +29,8 @@ Open `/projects`, create a project, and enter its overview or Issues view.
### `repository-settings`
Open `/projects/:projectId/configuration` and its repository/workspace settings.
Workspace storage shows the inherited installed default and allows an explicit
provider or Core/local choice. Conflicting defaults remain visible for repair.
### `project-access`
@@ -46,6 +48,8 @@ Manual: Create a project, add a task from its view, and reload. Verify the task
### `repository-settings`
Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover inherited choices, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage.
Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows.
Manual: Attach a small repository, save, and create a project task. Inspect the actual checkout/revision used by its run. Test an invalid repository and missing credentials without treating a saved URL as a working workspace.
+37 -4
View File
@@ -1315,10 +1315,43 @@ implement `onResourceLifecycle` and `onWorkspaceOperation`. Project lifecycle
creates and retains the storage; workspace operations manage task checkouts in
that storage. Execution remains a separate environment driver contract.
Set `project.executionWorkspacePolicy.storageProviderPluginId` to the installed
plugin's UUID. Core then reserves an execution-workspace row and binds the task
before it calls `onWorkspaceOperation`. Projects without this setting keep their
current local behavior. Existing managed workspaces retain their original owner.
Set `project.executionWorkspacePolicy.storageProviderPluginId` to an installed
plugin's UUID for an explicit selection, or `null` for explicit Core/local storage.
Omit the field to inherit the installed company-enabled default. A provider claims
that default by declaring, for example:
```ts
workspaceProvider: {
modes: ["isolated_workspace", "operator_branch"],
strategies: ["git_worktree"],
defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
}
```
The declared defaults must be supported by the provider. Core applies missing mode
and strategy settings to Git projects, including existing unconfigured projects,
without writing their policy. Explicit settings remain intact; incompatible
settings fail with a configuration error. Multiple enabled default claims require
an explicit project choice or disabling a conflicting provider. Worker health does
not change the selected default: unavailable providers fail without local fallback.
No company settings row means enabled, matching the normal plugin contract.
New and imported tasks leave an unspecified project mode inherited. Provider-owned
workspaces retain explicit project and task strategies in shared and operator-branch
modes. If neither supplies a strategy, those modes use `project_primary`; an agent's
Git strategy remains a fallback only for isolated mode.
Project responses keep the stored `executionWorkspacePolicy` and expose the
separate `workspaceProvider` configuration, including its source, effective policy,
provider choices and configuration error. The project configuration UI supports
inheritance, explicit plugins and Core/local storage. Conflicts remain readable so
operators can resolve them through this flow.
Core reserves an execution-workspace row and binds the task before calling
`onWorkspaceOperation`. Source-free task directories do not prepare project storage. The resolved provider
choice, including Core/local, stays fixed through source resolution and preparation.
Existing workspaces, including local workspaces, retain their original owner. Changing defaults never migrates, resets or recreates them;
normal retirement and cleanup gates still apply on disable/uninstall.
The hook receives a company, project, source revision, execution-workspace ID,
lifecycle generation, deadline, and stable operation ID. Run and agent IDs are
@@ -103,6 +103,13 @@ describe("workspace provider SDK", () => {
resourceLifecycle: ["project"],
};
expect(pluginManifestV1Schema.safeParse(manifest).success).toBe(true);
const withDefault = { ...manifest, workspaceProvider: { ...manifest.workspaceProvider,
defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } } };
expect(pluginManifestV1Schema.parse(withDefault).workspaceProvider?.defaultForProjects).toEqual({
mode: "isolated_workspace", strategy: "git_worktree",
});
expect(pluginManifestV1Schema.safeParse({ ...withDefault, workspaceProvider: { ...withDefault.workspaceProvider,
defaultForProjects: { mode: "shared_workspace", strategy: "git_worktree" } } }).success).toBe(false);
expect(
pluginManifestV1Schema.safeParse({
...manifest,
+1
View File
@@ -933,6 +933,7 @@ export type {
ProjectManagedByPlugin,
ProjectVisibility,
ProjectWorkspace,
ProjectWorkspaceProviderConfiguration,
CompanySearchCountType,
CompanySearchExtractIssueResult,
CompanySearchExtractKind,
+1
View File
@@ -367,6 +367,7 @@ export type {
ProjectManagedByPlugin,
ProjectVisibility,
ProjectWorkspace,
ProjectWorkspaceProviderConfiguration,
} from "./project.js";
export type {
CompanySearchCountType,
+4
View File
@@ -698,6 +698,10 @@ export interface PaperclipPluginManifestV1 {
workspaceProvider?: {
modes: Array<"shared_workspace" | "isolated_workspace" | "operator_branch">;
strategies: Array<"project_primary" | "git_worktree">;
defaultForProjects?: {
mode: "shared_workspace" | "isolated_workspace" | "operator_branch";
strategy: "project_primary" | "git_worktree";
};
};
/** Durable, acknowledged resource events delivered to onResourceLifecycle. Requires events.subscribe. */
resourceLifecycle?: Array<"agent" | "project" | "agent_runtime">;
+10
View File
@@ -101,6 +101,15 @@ export interface ProjectDiscoveryPage {
nextCursor: string | null;
}
export interface ProjectWorkspaceProviderConfiguration {
source: "project" | "default" | "core";
pluginId: string | null;
displayName: string;
providers: Array<{ id: string; displayName: string; isDefault: boolean }>;
effectivePolicy: ProjectExecutionWorkspacePolicy | null;
error: string | null;
}
export interface Project {
id: string;
companyId: string;
@@ -126,6 +135,7 @@ export interface Project {
pauseReason: PauseReason | null;
pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaceProvider?: ProjectWorkspaceProviderConfiguration;
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
@@ -171,6 +171,7 @@ export interface ExecutionWorkspaceCloseReadiness {
export interface ProjectExecutionWorkspacePolicy {
enabled: boolean;
storageProviderPluginId?: string | null;
sharedWorkspaceConcurrency?: SharedWorkspaceConcurrency;
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
allowIssueOverride?: boolean;
+8
View File
@@ -793,6 +793,10 @@ export const pluginManifestV1Schema = z.object({
workspaceProvider: z.object({
modes: z.array(z.enum(["shared_workspace", "isolated_workspace", "operator_branch"])).min(1).max(3),
strategies: z.array(z.enum(["project_primary", "git_worktree"])).min(1).max(2),
defaultForProjects: z.object({
mode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch"]),
strategy: z.enum(["project_primary", "git_worktree"]),
}).strict().optional(),
}).strict().optional(),
resourceLifecycle: z.array(z.enum(["agent", "project", "agent_runtime"])).min(1).max(3).optional(),
aiConnectionRouter: z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(500) }).strict().optional(),
@@ -826,6 +830,10 @@ export const pluginManifestV1Schema = z.object({
if (manifest.workspaceProvider && (!manifest.capabilities.includes("workspaces.manage") || !manifest.resourceLifecycle?.includes("project"))) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceProvider"], message: "Workspace providers require workspaces.manage and project resource lifecycle" });
}
const defaults = manifest.workspaceProvider?.defaultForProjects;
if (defaults && (!manifest.workspaceProvider!.modes.includes(defaults.mode) || !manifest.workspaceProvider!.strategies.includes(defaults.strategy))) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceProvider", "defaultForProjects"], message: "Project defaults must use a declared workspace mode and strategy" });
}
if (manifest.resourceLifecycle && !manifest.capabilities.includes("events.subscribe")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Resource lifecycle delivery requires events.subscribe", path: ["capabilities"] });
}
@@ -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);
@@ -138,3 +138,36 @@ it("hides project isolation controls without rewriting its policy", () => {
expect(container.querySelector('select[aria-label="Shared workspace concurrency"]')).toBeNull();
expect(onFieldUpdate).not.toHaveBeenCalled();
});
describe("ProjectProperties — workspace storage selection", () => {
const inherited = {
source: "default" as const, pluginId: "provider-one", displayName: "Managed storage", error: null,
effectivePolicy: { enabled: true, defaultMode: "isolated_workspace" as const, storageProviderPluginId: "provider-one", workspaceStrategy: { type: "git_worktree" as const } },
providers: [{ id: "provider-one", displayName: "Managed storage", isDefault: true }, { id: "provider-two", displayName: "Alternative", isDefault: false }],
};
function storageSelect() { return container.querySelector<HTMLSelectElement>('select[aria-label="Workspace storage provider"]')!; }
it("displays the inherited default without saving it as an override", () => {
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn());
expect(storageSelect().value).toBe("inherit");
expect(container.textContent).toContain("Inherited default: Managed storage");
});
it.each(["core", "provider-two", "inherit"])("saves the deliberate %s choice while preserving explicit settings", value => {
const onFieldUpdate = vi.fn();
render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one", sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate);
act(() => { storageSelect().value = value; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); });
const policy = { enabled: true, sharedWorkspaceConcurrency: "serialize", ...(value === "inherit" ? {} : { storageProviderPluginId: value === "core" ? null : value }) };
expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: policy });
});
it("does not copy inherited mode, strategy, or provider when another setting changes", () => {
const onFieldUpdate = vi.fn();
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), onFieldUpdate);
act(() => { concurrencySelect().value = "allow"; concurrencySelect().dispatchEvent(new Event("change", { bubbles: true })); });
expect(onFieldUpdate).toHaveBeenCalledWith("execution_workspace_shared_concurrency", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "allow" } });
});
it("shows default conflicts and keeps the provider control usable", () => {
render(makeProject({ workspaceProvider: { ...inherited, error: "Select a storage provider in project configuration" } }), vi.fn());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Select a storage provider");
expect(storageSelect().disabled).toBe(false);
});
});
+40 -4
View File
@@ -47,6 +47,7 @@ export type ProjectConfigFieldKey =
| "visibility"
| "goals"
| "env"
| "workspace_storage_provider"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_shared_concurrency"
@@ -273,7 +274,9 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
const codebase = project.codebase;
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const storedExecutionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const providerConfiguration = project.workspaceProvider;
const executionWorkspacePolicy = providerConfiguration?.effectivePolicy ?? storedExecutionWorkspacePolicy;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const executionWorkspaceDefaultMode =
@@ -352,9 +355,7 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
return {
executionWorkspacePolicy: {
enabled: executionWorkspacesEnabled,
defaultMode: executionWorkspaceDefaultMode,
allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true,
...executionWorkspacePolicy,
...storedExecutionWorkspacePolicy,
...patch,
},
};
@@ -729,6 +730,41 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
)}
</div>}
{providerConfiguration ? (
<div className="py-1.5 space-y-2">
<label className="flex items-center gap-2 text-sm font-medium" htmlFor="workspace-storage-provider">
Workspace storage
<SaveIndicator state={fieldState("workspace_storage_provider")} />
</label>
<select
id="workspace-storage-provider"
aria-label="Workspace storage provider"
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
disabled={!onUpdate && !onFieldUpdate}
value={storedExecutionWorkspacePolicy?.storageProviderPluginId === null ? "core" : storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "inherit"}
onChange={(event) => {
const policy = { enabled: true, ...storedExecutionWorkspacePolicy };
if (event.target.value === "inherit") delete policy.storageProviderPluginId;
else policy.storageProviderPluginId = event.target.value === "core" ? null : event.target.value;
commitField("workspace_storage_provider", { executionWorkspacePolicy: policy });
}}
>
<option value="inherit">Inherit installed default</option>
<option value="core">Core / local</option>
{providerConfiguration.providers.map(provider => (
<option key={provider.id} value={provider.id}>{provider.displayName}{provider.isDefault ? " (default)" : ""}</option>
))}
{storedExecutionWorkspacePolicy?.storageProviderPluginId && !providerConfiguration.providers.some(provider => provider.id === storedExecutionWorkspacePolicy.storageProviderPluginId) ? (
<option value={storedExecutionWorkspacePolicy.storageProviderPluginId}>Unavailable provider</option>
) : null}
</select>
<p className="text-xs text-muted-foreground">
{providerConfiguration.source === "default" ? `Inherited default: ${providerConfiguration.displayName}` : `Selected: ${providerConfiguration.displayName}`}
</p>
{providerConfiguration.error ? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
</div>
) : null}
{isolatedWorkspacesEnabled && workspaceIsolationControlsVisible ? (
<>
<Separator className="my-4" />