mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 11:23:40 +01:00
fix(projects): keep projects on the storage chosen at creation (#19036)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A project stores its work in Core/local storage or in a storage plugin > - Today, Core finds the installed default each time it reads a project > - So installing a default storage plugin moves existing projects, without a saved change > - Agents that cannot reach the new storage then lose access to new workspaces > - This pull request makes Core apply the default once, when it creates a project > - The benefit is that a project stays on its storage until a person moves it ## Linked Issues or Issue Description No issue exists. This describes the problem. I searched open pull requests for related work and found no duplicate. **What existing behavior does this improve?** How Core chooses the storage for a project when a plugin claims `defaultForProjects`. **Current behavior** Installing such a plugin changes the storage of every existing project with no explicit choice. Disabling the plugin changes it back. Nothing is saved, so the project has no fixed owner. Execution environments and storage are separate contracts, so an agent can lose access to new workspaces on a project that it used before. **Proposed behavior** Core saves the storage choice on the project when it creates the project. A project with no saved choice uses Core/local storage. A plugin-backed project stays attached to its plugin. A Core/local project can move to a plugin. **Reason and benefit** A project stays on its storage until a person moves it, so installing or disabling a plugin does not change where work goes. **Alternatives considered** - Keep the default at read time and add a process that saves a choice on existing projects when a plugin arrives. This adds a background step and a race with new projects. - Add a new database column. The existing policy field already holds the choice, so a migration is not necessary. ## What Changed - `resolveProjectWorkspaceProvider` reads only the saved choice. It no longer applies an installed default. - `withDefaultProjectStorage` applies the default when Core creates a project, including a personal private project. With more than one default, a project with no explicit choice is refused. This is the existing conflict rule, now applied at creation. - `projectService.update` refuses a change of storage on a plugin-backed project. An update that omits the field keeps the attachment. - Plugin lifecycle delivery sends project events to a storage provider only for projects attached to it, and for deleted projects. Core acknowledges the other events for that provider. Other plugins continue to receive all project events. - The project settings control has no "Inherit" option. It is locked on a plugin-backed project. - `ProjectWorkspaceProviderConfiguration.source` no longer has the `"default"` value. - A project with no repository or folder ignores its saved storage choice when a task runs. A storage plugin cannot manage this type of project, so its tasks use a plain task directory. Without this change, the second run of a task reset the agent session, and remote agents could not use an empty task directory. - The plugin SDK README and the plugin authoring guide describe the new rules. ## Verification - `npx vitest run server/src/__tests__/project-workspace-provider.integration.test.ts server/src/__tests__/plugin-lifecycle-delivery.test.ts server/src/__tests__/plugin-workspaces.integration.test.ts packages/plugins/sdk/tests/workspace-provider.test.ts`: 93 tests pass. - `npx vitest run src/components/ProjectProperties.concurrency.test.tsx` in `ui/`: 9 tests pass. - `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter @paperclipai/ui typecheck` pass. - `npx vitest run` on `heartbeat-session-preparation`, `workspace-preparation`, `remote-workspace-source`, `project-workspace-provider.integration`, and `plugin-workspaces.integration` in `server/`: 231 tests pass. - I did not run the full test suite locally. CI runs it. - I did not test this change on a running instance. ## Risks - **Behavior change at upgrade:** an existing project that uses a plugin only through the installed default, with no saved choice, moves to Core/local storage after upgrade. Its plugin files stay in place, but new work does not use them until an operator sets `storageProviderPluginId` on the project. This pull request has no automatic backfill. - **Missed lifecycle events:** a provider does not receive events for a Core/local project. If the project moves to that provider later, the update event starts its reconcile. - **One-way move:** a person cannot move a plugin-backed project back through the API or the UI. This is intended. If a plugin is disabled, the project fails closed, as it does today for an explicit choice. - This pull request does not stop a person from assigning an agent to a project with storage that the agent cannot reach. That is follow-up work. - This changes core plugin behavior, and I did not discuss it in `#dev` first. Maintainers can tell me if they want a discussion before review. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool use and code execution. ## 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 either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a0e7ab511
commit
be20d4ad97
16 files changed
+263
-129
No files matched your search
@@ -129,8 +129,9 @@ async onResourceLifecycle(event) {
|
||||
```
|
||||
|
||||
Core delivers events to ready plugins with a configuration row for that company,
|
||||
unless the plugin is disabled there. Each invocation has the event's company
|
||||
scope. The dispatcher wakes after the mutation's outer transaction settles,
|
||||
unless the plugin is disabled there. A workspace storage provider receives project
|
||||
events only for projects attached to it, plus events for deleted projects. Each
|
||||
invocation has the event's company scope. The dispatcher wakes after the mutation's outer transaction settles,
|
||||
on host startup, plugin activation/worker start, and company configuration or
|
||||
enablement changes. Successful worker `onConfigChanged` completion wakes delivery
|
||||
again, so a callback that deferred while configuration was still applying is
|
||||
|
||||
@@ -1398,10 +1398,10 @@ 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 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:
|
||||
`project.executionWorkspacePolicy.storageProviderPluginId` holds a project's storage:
|
||||
an installed plugin's UUID, or `null` or no value for Core/local storage. Core
|
||||
applies the installed company-enabled default once, when it creates the project. A
|
||||
provider claims that default by declaring, for example:
|
||||
|
||||
```ts
|
||||
workspaceProvider: {
|
||||
@@ -1411,13 +1411,16 @@ workspaceProvider: {
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
The declared defaults must be supported by the provider. Installing a provider
|
||||
does not change existing projects. A Core/local project can move to a provider, and
|
||||
a plugin-backed project stays attached to that plugin: requests to change it fail,
|
||||
and it never falls back to local storage. Core applies missing mode and strategy
|
||||
settings without writing them. Explicit settings remain intact; incompatible
|
||||
settings fail with a configuration error. If more than one enabled provider claims
|
||||
the default, creating a project needs an explicit choice. Core sends a provider
|
||||
project lifecycle events only for projects attached to it, and acknowledges the
|
||||
others on its behalf. 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
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface ProjectDiscoveryPage {
|
||||
}
|
||||
|
||||
export interface ProjectWorkspaceProviderConfiguration {
|
||||
source: "project" | "default" | "core";
|
||||
source: "project" | "core";
|
||||
pluginId: string | null;
|
||||
displayName: string;
|
||||
providers: Array<{ id: string; displayName: string; isDefault: boolean }>;
|
||||
|
||||
@@ -89,14 +89,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
|
||||
expect(resumed.finished.sessionIdBefore).toBe("saved-session");
|
||||
});
|
||||
|
||||
it("keeps inherited project policy stable after binding a repository-free workspace", async () => {
|
||||
it("keeps a default-attached project policy stable after binding a repository-free workspace", async () => {
|
||||
const f = await seed();
|
||||
const storage = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
|
||||
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
|
||||
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
|
||||
} } }).where(eq(plugins.id, storage.pluginId));
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Directory project" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Directory project", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).returning();
|
||||
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId));
|
||||
try {
|
||||
const first = await execute(f);
|
||||
@@ -116,14 +116,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
|
||||
null,
|
||||
{ mode: "isolated_workspace" },
|
||||
{ workspaceStrategy: { type: "git_worktree", branchTemplate: "work" } },
|
||||
])("keeps inherited repository policy stable through binding with settings %j and retains the provider on policy changes", async (settings) => {
|
||||
])("keeps a default-attached repository policy stable through binding with settings %j and retains the provider on policy changes", async (settings) => {
|
||||
const f = await seed();
|
||||
const storage = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
|
||||
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
|
||||
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
|
||||
} } }).where(eq(plugins.id, storage.pluginId));
|
||||
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, storage.input.projectId));
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId));
|
||||
await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId,
|
||||
executionWorkspaceSettings: settings,
|
||||
}).where(eq(issues.id, f.issueId));
|
||||
@@ -158,7 +158,7 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
|
||||
workspace: { action: "reuse", workspaceReused: true, changedCategories: [], activeWorkspaceId: bound.executionWorkspaceId },
|
||||
} });
|
||||
}
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, allowIssueOverride: false } }).where(eq(projects.id, storage.input.projectId));
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, allowIssueOverride: false, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId));
|
||||
const changed = await execute(f);
|
||||
expect(changed.input.runtime.sessionId).toBeNull();
|
||||
expect(changed.finished.resultJson).toMatchObject({ configFreshness: { session: { reset: true, changedCategories: ["workspaceConfig"] } } });
|
||||
|
||||
@@ -54,6 +54,40 @@ describePostgres("durable plugin lifecycle delivery", () => {
|
||||
const worker = registerPluginLifecycleDelivery(coordinator as never, db, { call, isRunning: running, onConfigApplied } as unknown as PluginWorkerManager, lifecycle as unknown as PluginLifecycleManager);
|
||||
return { call, running, wake, lifecycle, worker, applied: () => { for (const listener of configApplied) listener(); }, run: () => task.run(new AbortController().signal), pending: () => task.hasPending(), nextRunAt: () => task.nextRunAt?.(), settled: () => task.onSettled?.() };
|
||||
}
|
||||
it("delivers project events to a storage provider only for projects attached to it", async () => {
|
||||
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "workspaces.manage"], workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"] } } as never }).where(eq(plugins.id, pluginId));
|
||||
const [core] = await db.insert(projects).values({ companyId, name: "Core project" }).returning();
|
||||
const [attached] = await db.insert(projects).values({ companyId, name: "Attached project", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } }).returning();
|
||||
await event(core.id);
|
||||
await event(attached.id);
|
||||
const d = delivery();
|
||||
try {
|
||||
await d.run();
|
||||
expect(d.call).toHaveBeenCalledTimes(1);
|
||||
expect(d.call.mock.calls[0][2]).toMatchObject({ resourceId: attached.id });
|
||||
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } }).where(eq(projects.id, core.id));
|
||||
await event(core.id, "update");
|
||||
await d.run();
|
||||
expect(d.call).toHaveBeenCalledTimes(2);
|
||||
expect(d.call.mock.calls[1][2]).toMatchObject({ resourceId: core.id, action: "update" });
|
||||
} finally {
|
||||
d.worker.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers a deleted project's event to a storage provider", async () => {
|
||||
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "workspaces.manage"], workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"] } } as never }).where(eq(plugins.id, pluginId));
|
||||
const removed = await event(randomUUID(), "delete");
|
||||
const d = delivery();
|
||||
try {
|
||||
await d.run();
|
||||
expect(d.call.mock.calls[0][2]).toMatchObject({ resourceId: removed, action: "delete" });
|
||||
} finally {
|
||||
d.worker.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("supplies project native credentials only to opted-in callbacks and saves returned patches before acknowledgment", async () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "projects.instance-secrets"] } as never }).where(eq(plugins.id, pluginId));
|
||||
|
||||
@@ -558,8 +558,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
|
||||
const svc = projectService(db);
|
||||
await expect(
|
||||
svc.update(f.input.projectId, { executionWorkspacePolicy: {} }),
|
||||
).rejects.toThrow("storage owner");
|
||||
svc.update(f.input.projectId, {
|
||||
executionWorkspacePolicy: { storageProviderPluginId: null },
|
||||
}),
|
||||
).rejects.toThrow("stays attached");
|
||||
await expect(
|
||||
svc.updateWorkspace(f.input.projectId, f.input.projectWorkspaceId, {
|
||||
repoUrl: "https://github.com/example/other.git",
|
||||
|
||||
@@ -6,13 +6,13 @@ 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 { effectiveProjectWorkspacePolicy, listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, sourcelessProjectWorkspacePolicy } 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 { ensurePersonalPrivateProject, projectService } from "../services/projects.js";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
@@ -33,33 +33,55 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 () => {
|
||||
it("attaches a new project to the installed default and leaves existing projects where they are", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
|
||||
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" });
|
||||
expect(created.executionWorkspacePolicy).toMatchObject({ enabled: true, storageProviderPluginId: f.pluginId });
|
||||
expect(created.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId });
|
||||
const existing = await svc.getById(f.input.projectId);
|
||||
expect(existing!.executionWorkspacePolicy).toBeNull();
|
||||
expect(existing!.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
|
||||
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, null, true)).toBeNull();
|
||||
expect(await preparePluginWorkspace(db, f.workers, { ...f.input, selectedProviderPluginId: null })).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a project on Core storage when no default is installed, and keeps it there after one arrives", async () => {
|
||||
const f = await createPluginWorkspaceFixture(db);
|
||||
const svc = projectService(db);
|
||||
const created = await svc.create(f.companyId, { name: "Before the provider" });
|
||||
expect(created.executionWorkspacePolicy).toBeNull();
|
||||
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));
|
||||
expect((await svc.getById(created.id))!.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
|
||||
});
|
||||
|
||||
it("keeps an explicit creation choice", async () => {
|
||||
const f = await fixture();
|
||||
const other = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const svc = projectService(db);
|
||||
expect((await svc.create(f.companyId, { name: "Local", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).workspaceProvider)
|
||||
.toMatchObject({ source: "project", pluginId: null });
|
||||
expect((await svc.create(f.companyId, { name: "Other", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: other.pluginId } })).workspaceProvider)
|
||||
.toMatchObject({ source: "project", pluginId: other.pluginId });
|
||||
});
|
||||
|
||||
it("keeps a personal project on the installed default", async () => {
|
||||
const f = await fixture();
|
||||
const project = await ensurePersonalPrivateProject(db, f.companyId, "user-one");
|
||||
expect(project.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -72,7 +94,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
}
|
||||
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 policy = await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, 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 });
|
||||
@@ -82,17 +104,28 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 () => {
|
||||
it("keeps a plugin-backed project attached through every policy update", async () => {
|
||||
const f = await fixture();
|
||||
const other = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
|
||||
const svc = projectService(db);
|
||||
const kept = await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" } });
|
||||
expect(kept!.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId, sharedWorkspaceConcurrency: "serialize" });
|
||||
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: null }))!.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId });
|
||||
for (const storageProviderPluginId of [null, other.pluginId]) {
|
||||
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } }))
|
||||
.rejects.toThrow("stays attached");
|
||||
}
|
||||
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace);
|
||||
});
|
||||
|
||||
it("lets a Core project move to a storage plugin, and then keeps it there", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
|
||||
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");
|
||||
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).rejects.toThrow("stays attached");
|
||||
});
|
||||
|
||||
it("preserves explicit alternatives and the explicit Core/local choice", async () => {
|
||||
@@ -101,16 +134,16 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 });
|
||||
await db.update(projects).set({ executionWorkspacePolicy: local }).where(eq(projects.id, f.input.projectId));
|
||||
expect((await projectService(db).getById(f.input.projectId))!.executionWorkspacePolicy).toEqual(local);
|
||||
expect(await preparePluginWorkspace(db, f.workers, f.input)).toBeNull();
|
||||
expect(await preparePluginWorkspace(db, f.workers, { ...f.input, selectedProviderPluginId: null })).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["disabled", "uninstalled"] as const)("does not inherit an instance %s provider", async status => {
|
||||
it.each(["disabled", "uninstalled"] as const)("does not attach a new project to 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();
|
||||
expect((await projectService(db).create(f.companyId, { name: "New project" })).executionWorkspacePolicy).toBeNull();
|
||||
});
|
||||
|
||||
it("scopes company enablement and does not borrow another company's override", async () => {
|
||||
@@ -118,8 +151,9 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 });
|
||||
const svc = projectService(db);
|
||||
expect((await svc.create(f.companyId, { name: "Disabled here" })).executionWorkspacePolicy).toBeNull();
|
||||
expect((await svc.create(other.id, { name: "Enabled here" })).executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true))
|
||||
.rejects.toThrow("unavailable");
|
||||
});
|
||||
@@ -127,30 +161,30 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 });
|
||||
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, 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 () => {
|
||||
it("never falls back when the attached provider is disabled during preparation", async () => {
|
||||
const f = await fixture();
|
||||
const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, null, true);
|
||||
const policy = await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, 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 () => {
|
||||
it("asks for a choice when creating a project with conflicting defaults, and leaves existing projects working", 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 });
|
||||
const svc = projectService(db);
|
||||
await expect(svc.create(f.companyId, { name: "Ambiguous" })).rejects.toThrow("Multiple workspace storage plugins");
|
||||
expect((await svc.create(f.companyId, { name: "Chosen", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })).workspaceProvider)
|
||||
.toMatchObject({ pluginId: f.pluginId, error: null });
|
||||
expect((await svc.getById(f.input.projectId))!.workspaceProvider).toMatchObject({ pluginId: f.pluginId, error: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -159,14 +193,24 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
{ 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");
|
||||
await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { ...policy, storageProviderPluginId: f.pluginId }, true)).rejects.toThrow("explicit project workspace settings");
|
||||
});
|
||||
|
||||
it("ignores saved storage for a project with no source", () => {
|
||||
const pluginId = randomUUID();
|
||||
expect(sourcelessProjectWorkspacePolicy(null)).toBeNull();
|
||||
expect(sourcelessProjectWorkspacePolicy({ enabled: true, storageProviderPluginId: pluginId })).toBeNull();
|
||||
expect(sourcelessProjectWorkspacePolicy({ enabled: false, storageProviderPluginId: null })).toBeNull();
|
||||
expect(sourcelessProjectWorkspacePolicy({ enabled: true })).toEqual({ enabled: true });
|
||||
expect(sourcelessProjectWorkspacePolicy({ enabled: true, defaultMode: "isolated_workspace", storageProviderPluginId: pluginId }))
|
||||
.toEqual({ enabled: true, defaultMode: "isolated_workspace" });
|
||||
});
|
||||
|
||||
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 });
|
||||
const policy = { enabled: true, defaultMode: "operator_branch", storageProviderPluginId: f.pluginId, workspaceStrategy: { type: "git_worktree", baseRef: "release", branchTemplate: "kept" } };
|
||||
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, policy, true)).toEqual(policy);
|
||||
expect(await effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, false)).toEqual({ enabled: true, storageProviderPluginId: f.pluginId });
|
||||
});
|
||||
|
||||
it("retains both plugin and local workspace ownership when defaults change", async () => {
|
||||
@@ -183,7 +227,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 () => {
|
||||
it("exposes attachment through the normal API with company access checks", async () => {
|
||||
const f = await fixture();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -191,14 +235,12 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
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 });
|
||||
expect((await request(app).get(url)).body.workspaceProvider).toMatchObject({ source: "project", 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 });
|
||||
expect(patch.status).toBe(409);
|
||||
const kept = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true } });
|
||||
expect(kept.status).toBe(200);
|
||||
expect(kept.body.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: 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);
|
||||
|
||||
@@ -468,7 +468,6 @@ import {
|
||||
parseIssueAssigneeAdapterOverrides,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
selectEnvironmentExecutionWorkspaceSettings,
|
||||
} from "./execution-workspace-policy.js";
|
||||
@@ -532,7 +531,7 @@ import {
|
||||
|
||||
import { resolveAndRetainRunTrustPreset } from "./run-trust-preset.js";
|
||||
|
||||
import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js";
|
||||
import { effectiveProjectWorkspacePolicy, sourcelessProjectWorkspacePolicy } from "./project-workspace-provider.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js";
|
||||
@@ -2439,7 +2438,7 @@ export function heartbeatService(
|
||||
);
|
||||
const parsedProjectExecutionWorkspacePolicy = workspacePolicyContext?.hasWorkspace
|
||||
? await effectiveProjectWorkspacePolicy(db, agent.companyId, workspacePolicyContext.executionWorkspacePolicy, workspacePolicyContext.hasGitSource)
|
||||
: parseProjectExecutionWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy);
|
||||
: sourcelessProjectWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy);
|
||||
const projectExecutionWorkspacePolicy =
|
||||
applyDefaultIsolatedExecutionWorkspacePolicy({
|
||||
projectPolicy: gateProjectExecutionWorkspacePolicy(
|
||||
|
||||
@@ -105,6 +105,17 @@ describe.skipIf(!support.supported)("remote referenced project sources", () => {
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ enabled: true, storageProviderPluginId: randomUUID() },
|
||||
{ enabled: false, storageProviderPluginId: null },
|
||||
])("keeps the private empty source for a project with no repository and saved storage %j", async (policy) => {
|
||||
const f = await assignedTask();
|
||||
await db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId));
|
||||
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: f.projectId, projectWorkspaceId: null });
|
||||
expect(result).toMatchObject({ authority: "remote", source: { kind: "empty" }, projectId: null, projectWorkspaceId: null });
|
||||
});
|
||||
|
||||
it("does not use a foreign organizational project as private scratch authority", async () => {
|
||||
const f = await assignedTask(), foreignCompanyId = randomUUID(), foreignProjectId = randomUUID();
|
||||
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign owner", issuePrefix: foreignCompanyId.slice(0, 8) });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { asc, and, eq, isNull, notExists } from "drizzle-orm";
|
||||
import { asc, and, eq, notExists } from "drizzle-orm";
|
||||
import { projects, projectWorkspaces, type Db } from "@paperclipai/db";
|
||||
import type { TaskWorkspaceIntent } from "@paperclipai/shared";
|
||||
import { HttpError } from "../../errors.js";
|
||||
import { sourcelessProjectWorkspacePolicy } from "../project-workspace-provider.js";
|
||||
import { ProjectAccessDeniedError } from "../project-access-denial.js";
|
||||
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../task-workspace-access.js";
|
||||
import { assertSelectedWorkspaceTrust } from "../task-repository-acquisition.js";
|
||||
@@ -152,13 +153,13 @@ export async function resolveRemoteWorkspaceSource(db: Db, input: {
|
||||
&& selected.task.executionWorkspaceId == null && selected.task.projectWorkspaceId == null
|
||||
&& selected.task.workspaceSelection == null && selected.task.workspacePendingSelection == null
|
||||
&& input.ref == null && input.branchName == null && input.branchMode !== "existing") {
|
||||
const [metadataProject] = await db.select({ id: projects.id }).from(projects).where(and(
|
||||
eq(projects.id, projectId), eq(projects.companyId, input.companyId), isNull(projects.executionWorkspacePolicy),
|
||||
const [metadataProject] = await db.select({ policy: projects.executionWorkspacePolicy }).from(projects).where(and(
|
||||
eq(projects.id, projectId), eq(projects.companyId, input.companyId),
|
||||
notExists(db.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(and(
|
||||
eq(projectWorkspaces.projectId, projects.id), eq(projectWorkspaces.companyId, input.companyId),
|
||||
))),
|
||||
)).limit(1);
|
||||
implicitTaskDirectory = Boolean(metadataProject);
|
||||
implicitTaskDirectory = Boolean(metadataProject) && sourcelessProjectWorkspacePolicy(metadataProject.policy) === null;
|
||||
}
|
||||
if (sourceId || projectId && !implicitTaskDirectory) throw new WorkspaceValidationFailure(
|
||||
"The selected project has no repository source for a remote workspace.",
|
||||
|
||||
@@ -795,21 +795,21 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ inherited: false, mode: "isolated_workspace" as const, fresh: false },
|
||||
{ inherited: false, mode: "isolated_workspace" as const, fresh: true },
|
||||
{ inherited: true, mode: "isolated_workspace" as const, fresh: false },
|
||||
{ inherited: true, mode: "operator_branch" as const, fresh: false },
|
||||
])("prepares managed storage before acquisition (inherited: $inherited, mode: $mode, fresh: $fresh) without touching a Core checkout", async ({ inherited, mode: defaultMode, fresh }) => {
|
||||
{ fromDefault: false, mode: "isolated_workspace" as const, fresh: false },
|
||||
{ fromDefault: false, mode: "isolated_workspace" as const, fresh: true },
|
||||
{ fromDefault: true, mode: "isolated_workspace" as const, fresh: false },
|
||||
{ fromDefault: true, mode: "operator_branch" as const, fresh: false },
|
||||
])("prepares managed storage before acquisition (fromDefault: $fromDefault, mode: $mode, fresh: $fresh) without touching a Core checkout", async ({ fromDefault, mode: defaultMode, fresh }) => {
|
||||
const { input, source, issue } = await fixture(true);
|
||||
const pluginKey = `test.managed-storage-${inherited}-${defaultMode}-${fresh}`;
|
||||
const pluginKey = `test.managed-storage-${fromDefault}-${defaultMode}-${fresh}`;
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey, packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"],
|
||||
manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"],
|
||||
workspaceProvider: { freshWorkspaceVersion: 1, 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,
|
||||
workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
const savedPolicy = fromDefault ? { enabled: true, storageProviderPluginId: plugin.id } : { 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!));
|
||||
await db.update(projects).set({ executionWorkspacePolicy: savedPolicy }).where(eq(projects.id, issue.projectId!));
|
||||
{
|
||||
const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, inherited ? null : explicitPolicy, true);
|
||||
const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, savedPolicy, true);
|
||||
const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: null, legacyUseProjectWorkspace: null });
|
||||
input.policy.projectExecutionWorkspacePolicy = policy;
|
||||
input.policy.requestedExecutionWorkspaceMode = mode;
|
||||
@@ -840,7 +840,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
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, mode: defaultMode, strategyType: "git_worktree", branchName: fresh ? null : inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" });
|
||||
expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, mode: defaultMode, strategyType: "git_worktree", branchName: fresh ? null : fromDefault ? "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",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { projectInstanceSecretService } from "./project-instance-secrets.js";
|
||||
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { pluginCompanySettings, pluginConfig, plugins, type Db } from "@paperclipai/db";
|
||||
import { pluginCompanySettings, pluginConfig, plugins, projects, type Db } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { isWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
|
||||
@@ -75,6 +77,16 @@ export function registerPluginLifecycleDelivery(
|
||||
.where(and(eq(plugins.id, plugin.id), eq(plugins.status, "ready"),
|
||||
or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true))));
|
||||
if (!enabled?.manifest.resourceLifecycle?.includes(event.resourceType) || !enabled.manifest.capabilities.includes("events.subscribe")) break;
|
||||
if (event.resourceType === "project" && isWorkspaceProvider(enabled.manifest)) {
|
||||
stage = "ownership";
|
||||
const [project] = await db.select({ policy: projects.executionWorkspacePolicy }).from(projects)
|
||||
.where(and(eq(projects.id, event.resourceId), eq(projects.companyId, companyId)));
|
||||
if (project && parseProjectExecutionWorkspacePolicy(project.policy)?.storageProviderPluginId !== plugin.id) {
|
||||
await inbox.acknowledge(companyId, event.id);
|
||||
wake();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stage = "credentials";
|
||||
const credentials = event.resourceType === "project" && enabled.manifest.capabilities.includes("projects.instance-secrets")
|
||||
? await projectInstanceSecretService(db, plugin.id).prepare(event) : undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
|
||||
import type { PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { conflict } from "../errors.js";
|
||||
|
||||
@@ -30,13 +30,9 @@ export function resolveProjectWorkspaceProvider(
|
||||
): 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 pluginId = policy?.storageProviderPluginId ?? null;
|
||||
const selected = pluginId ? providers.find(plugin => plugin.id === pluginId) : undefined;
|
||||
let error: string | null = null;
|
||||
const declaration = selected?.manifestJson.workspaceProvider;
|
||||
const supplied = hasGitSource ? declaration?.defaultForProjects : undefined;
|
||||
const effectivePolicy = pluginId ? parseProjectExecutionWorkspacePolicy({
|
||||
@@ -55,7 +51,7 @@ export function resolveProjectWorkspaceProvider(
|
||||
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",
|
||||
source: explicit ? "project" : "core",
|
||||
pluginId,
|
||||
displayName: selected?.manifestJson.displayName ?? (pluginId ? "Unavailable provider" : "Core / local"),
|
||||
providers: providers.map(plugin => ({ id: plugin.id, displayName: plugin.manifestJson.displayName,
|
||||
@@ -65,6 +61,24 @@ export function resolveProjectWorkspaceProvider(
|
||||
};
|
||||
}
|
||||
|
||||
export function sourcelessProjectWorkspacePolicy(rawPolicy: unknown): ProjectExecutionWorkspacePolicy | null {
|
||||
const policy = parseProjectExecutionWorkspacePolicy(rawPolicy);
|
||||
if (policy?.storageProviderPluginId === undefined) return policy;
|
||||
const { storageProviderPluginId: _storage, ...settings } = policy;
|
||||
return Object.keys(settings).some(key => key !== "enabled") ? settings : null;
|
||||
}
|
||||
|
||||
export async function withDefaultProjectStorage(db: ProviderDb, companyId: string, rawPolicy: unknown) {
|
||||
if (parseProjectExecutionWorkspacePolicy(rawPolicy)?.storageProviderPluginId !== undefined) return rawPolicy;
|
||||
const defaults = (await listProjectWorkspaceProviders(db, companyId))
|
||||
.filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects);
|
||||
if (defaults.length > 1) {
|
||||
throw conflict(`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 provider.`);
|
||||
}
|
||||
if (!defaults[0]) return rawPolicy;
|
||||
return { enabled: true, ...(rawPolicy as Record<string, unknown> | null), storageProviderPluginId: defaults[0].id };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, withDefaultProjectStorage } 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";
|
||||
@@ -115,6 +115,7 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin
|
||||
visibility: "private",
|
||||
personalOwnerUserId: userId,
|
||||
privacyOwnerUserId: userId,
|
||||
executionWorkspacePolicy: await withDefaultProjectStorage(dbOrTx, companyId, null) as Record<string, unknown> | null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
@@ -673,12 +674,13 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
// skipped validation whenever `goalIds: []` and `goalId` arrive
|
||||
// together (goalIds wins resolution, mirroring the update path).
|
||||
const legacyGoalId = ids?.[0] ?? null;
|
||||
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null) as typeof projectData.executionWorkspacePolicy;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const row = await tx
|
||||
.insert(projects)
|
||||
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
||||
.values({ ...projectData, executionWorkspacePolicy, goalId: legacyGoalId, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
if (ids && ids.length > 0) {
|
||||
@@ -979,12 +981,19 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
.where(eq(projects.id, id)).for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
if (projectData.executionWorkspacePolicy !== undefined &&
|
||||
projectData.executionWorkspacePolicy?.storageProviderPluginId !== existingProject.executionWorkspacePolicy?.storageProviderPluginId) {
|
||||
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 (projectData.executionWorkspacePolicy !== undefined) {
|
||||
const attachedPluginId = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId;
|
||||
const requestedPluginId = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
|
||||
if (attachedPluginId) {
|
||||
if (requestedPluginId !== undefined && requestedPluginId !== attachedPluginId) {
|
||||
throw conflict("A plugin-backed project stays attached to its storage plugin");
|
||||
}
|
||||
if (requestedPluginId === undefined) {
|
||||
projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: attachedPluginId };
|
||||
}
|
||||
} else if (requestedPluginId) {
|
||||
await retireRemovedPluginWorkspaceDependents(db, id);
|
||||
}
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
|
||||
|
||||
@@ -142,22 +142,29 @@ it("hides project isolation controls without rewriting its policy", () => {
|
||||
|
||||
describe("ProjectProperties — workspace storage selection", () => {
|
||||
const inherited = {
|
||||
source: "default" as const, pluginId: "provider-one", displayName: "Managed storage", error: null,
|
||||
source: "core" as const, pluginId: null, 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", () => {
|
||||
it("shows an unattached project on Core storage and offers each plugin", () => {
|
||||
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn());
|
||||
expect(storageSelect().value).toBe("inherit");
|
||||
expect(container.textContent).toContain("Inherited default: Managed storage");
|
||||
expect(storageSelect().value).toBe("core");
|
||||
expect(storageSelect().disabled).toBe(false);
|
||||
expect(Array.from(storageSelect().options).map(option => option.value)).toEqual(["core", "provider-one", "provider-two"]);
|
||||
expect(container.textContent).toContain("A project moved to a storage plugin stays on that plugin.");
|
||||
});
|
||||
it.each(["core", "provider-two", "inherit"])("saves the deliberate %s choice while preserving explicit settings", value => {
|
||||
it("saves a move to a plugin while preserving explicit settings", () => {
|
||||
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 });
|
||||
render(makeProject({ executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate);
|
||||
act(() => { storageSelect().value = "provider-two"; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize", storageProviderPluginId: "provider-two" } });
|
||||
});
|
||||
it("locks the control on a plugin-backed project", () => {
|
||||
render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" }, workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn());
|
||||
expect(storageSelect().value).toBe("provider-one");
|
||||
expect(storageSelect().disabled).toBe(true);
|
||||
expect(container.textContent).toContain("This project stays on Managed storage.");
|
||||
});
|
||||
it("does not copy inherited mode, strategy, or provider when another setting changes", () => {
|
||||
const onFieldUpdate = vi.fn();
|
||||
@@ -165,9 +172,8 @@ describe("ProjectProperties — workspace storage selection", () => {
|
||||
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);
|
||||
it("shows a provider configuration error", () => {
|
||||
render(makeProject({ workspaceProvider: { ...inherited, error: "The workspace storage plugin is unavailable" } }), vi.fn());
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("unavailable");
|
||||
});
|
||||
});
|
||||
@@ -742,26 +742,26 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
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"}
|
||||
disabled={Boolean(storedExecutionWorkspacePolicy?.storageProviderPluginId) || (!onUpdate && !onFieldUpdate)}
|
||||
value={storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "core"}
|
||||
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;
|
||||
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>
|
||||
<option key={provider.id} value={provider.id}>{provider.displayName}</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}`}
|
||||
{storedExecutionWorkspacePolicy?.storageProviderPluginId
|
||||
? `This project stays on ${providerConfiguration.displayName}.`
|
||||
: "A project moved to a storage plugin stays on that plugin."}
|
||||
</p>
|
||||
{providerConfiguration.error ? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user