mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
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 on5d42998466. All review threads are resolved. - Final GitHub CI is fully green on5d42998466. Browser shard 3 initially hit a PostgreSQL deadlock during cleanup cancellation, which left an experiment flag enabled for a later test. A failed-job retry passed all 26 browser tests. The workflow completed successfully on attempt 2. Full final verification was delegated to CI at the user's request. - No live provider or hosted tenant run was performed. ## Risks - Installing one default-claiming provider changes effective selection for unconfigured projects. Existing explicit settings and workspace ownership remain intact; incompatible explicit settings fail clearly. - Each configuration/admission resolution reads installed plugin metadata and the selected company's enablement. Project list reads share one catalog query. Selection does not call workers or external providers. - Deploy the Core contract before any downstream manifest uses the declaration. No migration or backfill is required. A downstream immutable image pin must include this change before packaging its declaration. - No merge or deployment is authorized by this PR. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The exact served model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run targeted tests locally and they pass; full gates are delegated to CI at the user's request - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green on 5d42998466ad42400564ba34f48124f40c53fbc5; the branch has no merge conflicts - [x] Standard Greptile is 5/5 on5d4299846after all fixes, with no unresolved threads - [x] Apex is 5/5 on5d42998466, after standard Greptile 5/5 on that same head - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6d44b80a5e
commit
6204239cfc
24 files changed
+577
-61
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -933,6 +933,7 @@ export type {
|
||||
ProjectManagedByPlugin,
|
||||
ProjectVisibility,
|
||||
ProjectWorkspace,
|
||||
ProjectWorkspaceProviderConfiguration,
|
||||
CompanySearchCountType,
|
||||
CompanySearchExtractIssueResult,
|
||||
CompanySearchExtractKind,
|
||||
|
||||
@@ -367,6 +367,7 @@ export type {
|
||||
ProjectManagedByPlugin,
|
||||
ProjectVisibility,
|
||||
ProjectWorkspace,
|
||||
ProjectWorkspaceProviderConfiguration,
|
||||
} from "./project.js";
|
||||
export type {
|
||||
CompanySearchCountType,
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user