mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A repository project gets its working files from core staging or from a workspace storage plugin > - #20459 reported that an installed storage plugin attached every new repository project, so sandbox agents could not use those projects > - #20587 fixed creation with an **Available in** choice that selects storage > - #20651 then saved an environment on every new project and used it before the agent default to place runs. That solved a problem that did not exist: the project needs storage, not a run environment > - Creation saves the first environment of the chosen group, so a project silently sends every agent's runs to that environment. Project Configuration also shows two controls (Environment and Workspace storage) for one decision > - This pull request removes the project run environment and makes storage the only placement choice a project owns. Storage is fixed at creation > - The benefit is that runs follow the agent again, and project settings show one clear, read-only fact about storage ## Linked Issues or Issue Description Refs #20459. Follow-up to #20587 and #20651. Refs #11644. That issue asks for a project-level run environment override. This pull request removes the override that #20651 added for it, because the override is not necessary to fix #20459. #11644 stays a separate feature request. Related: #18176 (open) repairs placement through the legacy workspace-policy field. This pull request does not restore project placement, so it conflicts with the direction of #18176. ## What Changed - **Remove the project run environment.** Migration `0466` drops `projects.environment_id`. Run placement no longer reads the project. Runs use the agent default, then the instance default, then local execution. The low-trust task sandbox and the enforced sandbox policies keep their precedence. - **Keep the #20651 recovery and binding guards.** A recovered run still uses its admitted environment lease (source `run`). Company-binding checks before admission and during lease creation stay. - **Keep the creation choice.** **Available in** in New Project still selects storage. It still shows only when the environments need more than one storage owner. That can only occur when a storage plugin is installed. `POST /projects` with `environmentId` selects the storage of that environment and does not save the environment. - **Fix storage at creation.** `PATCH /projects/:id` rejects any storage change with `409 project_storage_locked`, from core to a plugin and from a plugin to core. Before, a core project could move to a plugin. - **Drop legacy writes.** Older clients can still send `executionWorkspacePolicy.environmentId`. The service removes it on create and update. - **Simplify Project Configuration.** Remove the Environment and Workspace storage selectors. Show an advisory **Available in** list of the environments that can use the project's storage. Show it when a storage plugin is installed or when the project uses one. When the project's plugin is not installed, show **Storage plugin unavailable**. Without a storage plugin, show nothing. - **Issue workspace card.** Remove the "project default" environment label. - **Shared contract.** Remove `Project.environmentId`, the `environmentId` project field, and `expectedStorageProviderPluginId` from the update schema. The create schema keeps `environmentId` and `expectedStorageProviderPluginId` from #20587. - **Docs and maps.** Update `doc/project-environment-compatibility.md`, `docs/api/goals-and-projects.md`, and the project feature and agent-access maps. - **SDK source pins.** Refresh `tests/runner-e2e/source-sdk-public-inputs.json` for the two shared files. The shared file blobs return to their values before #20651. The refresh also corrects the `pnpm-lock.yaml` pin and `lockSha256`: `master` pins an older lockfile blob than the one it contains. ## Verification - Targeted server suites pass (227 tests): `project-environments`, `project-workspace-provider.integration`, `heartbeat/environment-selection`, `execution-workspace-policy`, `environment-selection-route-guards`, `environment-run-orchestrator`, `openapi-routes`. - Related server suites pass (349 tests): `environment-service`, `environment-routes`, `plugin-workspaces.integration`, `issues-service`. - Targeted UI suites pass: `ProjectProperties.concurrency`, `IssueWorkspaceCard`, `NewProjectDialog`, `NewProjectDialog.managed-sandbox`, `ProjectDetail`. - New tests: - Creation saves storage, not an environment. Core and plugin projects both reject a storage change with `project_storage_locked`. - A legacy policy `environmentId` is dropped on create and update. - A new run follows the agent default for any project. A recovered run keeps its admitted lease after the agent default changes. - Project Configuration has no storage or environment selector. The note is hidden without a plugin. It lists the core or plugin environments for the project's storage. It shows **Storage plugin unavailable** for a removed plugin. - `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter @paperclipai/ui typecheck`, and `pnpm --filter @paperclipai/shared typecheck` pass. - `pnpm --filter @paperclipai/db check:migrations`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check` pass. - Not run locally: the full `pnpm test:run`, `pnpm build`, and browser suites. CI supplies that coverage. - Manual check: open Project Configuration on an instance with a storage plugin. Confirm that there is no Environment or Workspace storage selector, and that **Available in** lists the environments. ## Risks - **Migration.** `0466` drops `projects.environment_id` and its foreign key. The migration deletes the saved project environments. Apply it before you serve the new revision. An old server revision that reads `projects` fails until it stops. This is the same pattern as earlier drop-column migrations (`0229`, `0289`). - **Behavior change for existing projects.** Projects created after #20651 shipped have a saved environment that overrides the agent. After this change, their runs follow the agent default again. This is the intended fix. Before #20651, a project environment selection was not applied to runs (#11644), so no older behavior is lost. - **No path from core to a plugin.** A core project, including one created before a plugin was installed, cannot move to the plugin. To use plugin storage, create a new project. - **API.** A `PATCH` that sends `environmentId` or `expectedStorageProviderPluginId` no longer has an effect. The schema strips these fields. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, in Claude Code, with tool use (shell, file edits, test runs). Extended thinking was available. ## 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 - [x] All Paperclip CI gates are green - [x] 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>
256 lines
18 KiB
TypeScript
256 lines
18 KiB
TypeScript
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, 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 { ensurePersonalPrivateProject, 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));
|
|
return f;
|
|
}
|
|
|
|
it("keeps a new project on core storage when no environment selects the installed plugin", 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" });
|
|
expect(created.executionWorkspacePolicy).toBeNull();
|
|
expect(created.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
|
|
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("does not attach a personal project based only on a plugin default claim", async () => {
|
|
const f = await fixture();
|
|
const project = await ensurePersonalPrivateProject(db, f.companyId, "user-one");
|
|
expect(project.executionWorkspacePolicy).toBeNull();
|
|
});
|
|
|
|
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 });
|
|
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, 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 });
|
|
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("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.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
|
}
|
|
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace);
|
|
});
|
|
|
|
it("keeps a Core project on Core storage", async () => {
|
|
const f = await fixture();
|
|
const other = await createPluginWorkspaceFixture(db, f.companyId);
|
|
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
|
|
const svc = projectService(db);
|
|
for (const storageProviderPluginId of [f.pluginId, other.pluginId]) {
|
|
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } }))
|
|
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
|
}
|
|
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }))!.workspaceProvider)
|
|
.toMatchObject({ pluginId: null });
|
|
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" } }))!.workspaceProvider)
|
|
.toMatchObject({ pluginId: null });
|
|
});
|
|
|
|
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 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, selectedProviderPluginId: null })).toBeNull();
|
|
expect(f.calls).toHaveLength(0);
|
|
});
|
|
|
|
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 projectService(db).create(f.companyId, { name: "New project" })).executionWorkspacePolicy).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 });
|
|
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: { enabled: true, storageProviderPluginId: f.pluginId } })).executionWorkspacePolicy).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, { 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 the attached provider is disabled during preparation", async () => {
|
|
const f = await fixture();
|
|
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("ignores conflicting storage default claims without a matching environment and preserves explicit choices", 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));
|
|
const svc = projectService(db);
|
|
expect((await svc.create(f.companyId, { name: "Core" })).workspaceProvider).toMatchObject({ pluginId: null, error: null });
|
|
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([
|
|
{ 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, 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", 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 () => {
|
|
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 attachment through the normal API 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: "project", pluginId: f.pluginId });
|
|
const patch = await request(app).patch(url).send({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } });
|
|
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);
|
|
expect((await request(denied).get(url)).status).toBe(404);
|
|
});
|
|
});
|