diff --git a/doc/project-environment-compatibility.md b/doc/project-environment-compatibility.md index f719861899d..47ed72bb872 100644 --- a/doc/project-environment-compatibility.md +++ b/doc/project-environment-compatibility.md @@ -2,9 +2,9 @@ A repository project belongs to one storage owner. Creation shows **Available in** when the configured environments require more than one owner. Each choice names -all environments that use that owner. An environment choice saves both the storage owner and the project's -`environmentId`. This is a project-owned execution target, not a company or agent -setting. Compatible environments can share its repository storage. +all environments that use that owner. The choice selects storage only. It is not +an allowlist, and it does not override an agent's run environment. A project has +no run environment of its own. ## Compatibility rule @@ -48,10 +48,9 @@ provider configs, credentials, endpoints, or host paths. A cold worker does not change a durable storage choice. `POST /api/companies/:companyId/projects` accepts an optional `environmentId`. -The server validates the choice again and stores its storage owner and environment -on that project. Creation saves the effective default once; changing the instance -or primary agent later does not rewrite existing projects. Compatible environments -share repository storage; the saved environment is the run target. An explicit `executionWorkspacePolicy.storageProviderPluginId` +The server validates the choice again and stores only its storage owner. The +project is then available to the compatible environments in that group; no single +run environment is saved. An explicit `executionWorkspacePolicy.storageProviderPluginId` continues to work. The UI sends both the environment and its observed storage owner in `expectedStorageProviderPluginId`, so a changed environment cannot silently select different storage. This guard does not create a workspace-mode @@ -73,25 +72,18 @@ workspace provider operation, with a message naming the storage and environment. ## Project Configuration -`PATCH /api/projects/:id` accepts `environmentId` and the optional -`expectedStorageProviderPluginId` precondition. The Environment selector reads -and writes this project field, independently of checkout/isolation settings. -Changing one project never changes another project, the agent, or instance settings. -Only environments compatible with the project's current storage are offered. -Changing storage ownership is subject to the existing retained-data safeguards. +Storage is fixed when the project is created. `PATCH /api/projects/:id` rejects a +change of `executionWorkspacePolicy.storageProviderPluginId` with `409` +`project_storage_locked`, in both directions: core to plugin and plugin to core. +A policy update that omits the storage owner keeps the stored owner. -Ordinary run selection uses the project's saved target before the agent default, -instance default, and local fallback. A null project target explicitly inherits -those defaults. An operator-selected low-trust task sandbox and enforced sandbox -policies retain precedence. Provider API execution keeps its existing placement. +Project Configuration has no storage or environment control. When a storage plugin +is installed, or the project uses one, it shows an advisory **Available in** list: +the environments that can use the project's storage. If the project's storage +plugin is no longer installed, it shows **Storage plugin unavailable**. Without a +storage plugin, the advisory is hidden. -The migration preserves existing valid project-policy environment selections in -the new project field. Older clients may still write the policy field; the service -normalizes it into the project association. Existing projects with no selection -remain unset. Environment deletion counts and clears only matching associations. - -Changing or clearing placement requires project workspace-write authority. A -saved selection is checked against company bindings before runtime admission and -again during lease creation. Recovery uses the admitted run's owned environment -lease; a later project edit affects new admissions. The task's organizational -project owns placement even when its files come from another project. +Runs use the agent default, then the instance default, then local execution. An +operator-selected low-trust task sandbox and enforced sandbox policies retain +precedence. A project does not change run placement. Older clients may still send +`executionWorkspacePolicy.environmentId`; the service drops it. diff --git a/docs/api/goals-and-projects.md b/docs/api/goals-and-projects.md index 650ea14041a..2436c9a4daf 100644 --- a/docs/api/goals-and-projects.md +++ b/docs/api/goals-and-projects.md @@ -61,7 +61,7 @@ GET /api/companies/{companyId}/projects GET /api/projects/{projectId} ``` -Returns project details including workspaces and the project-owned `environmentId`. +Returns project details including workspaces. ### Create Project @@ -84,9 +84,10 @@ POST /api/companies/{companyId}/projects Notes: -- `environmentId` optionally selects the project environment. When omitted, creation - saves the creator's effective default. The saved choice does not change when the - creator or instance default changes later. +- `environmentId` optionally selects the project's workspace storage: the storage + that this environment uses. When omitted, storage follows the creator's effective + default environment. The project does not save the environment, and it does not + change where runs execute. - `expectedStorageProviderPluginId` optionally guards the observed storage owner. Use `GET /api/companies/{companyId}/project-environments` to discover compatible groups and the creator's default. Unknown or changed storage is rejected. @@ -103,11 +104,9 @@ PATCH /api/projects/{projectId} } ``` -`environmentId` can be updated independently of workspace/isolation policy. Set it -to an available compatible environment ID, or `null` to inherit agent/instance -defaults. The update affects only this project. An incompatible or unavailable -environment returns `409`; company access is required. Existing plugin-owned data -cannot be moved to another storage owner through this field. +Workspace storage is fixed at creation. A change of +`executionWorkspacePolicy.storageProviderPluginId` returns `409` +(`project_storage_locked`). Runs use the agent's environment, not a project setting. ## Project Workspaces diff --git a/maps/agent-access-map/projects.md b/maps/agent-access-map/projects.md index 32bd0a3b74b..d24acd44f69 100644 --- a/maps/agent-access-map/projects.md +++ b/maps/agent-access-map/projects.md @@ -151,6 +151,6 @@ Gaps: The dedicated create_project tool does not advertise a visibility field; e Project configuration responses also expose effective workspace storage through `workspaceProvider`, separate from the stored execution policy. Existing project -read/update permissions apply. Omitted provider IDs inherit; null explicitly selects -Core/local. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts) +read/update permissions apply. Omitted provider IDs keep the stored owner; null explicitly selects +Core/local at creation. An update that changes the owner returns `409` `project_storage_locked`. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts) checks overrides and cross-company denial; live agent credential verification remains unqualified. diff --git a/maps/feature-map/projects.md b/maps/feature-map/projects.md index 42bb57238d1..ade1a4e0eeb 100644 --- a/maps/feature-map/projects.md +++ b/maps/feature-map/projects.md @@ -29,8 +29,9 @@ Open `/projects`, create a project, and enter its overview or Issues view. ### `repository-settings` Open `/projects/:projectId/configuration` and its repository/workspace settings. -Workspace storage shows the saved provider or Core/local choice. Plugin-backed -projects retain their storage owner. +Storage is fixed at creation and has no control here. When a storage plugin is +installed, or the project uses one, **Available in** lists the environments that +can use the project's storage. ### `project-access` diff --git a/packages/db/src/migrations/0466_drop_project_environment.sql b/packages/db/src/migrations/0466_drop_project_environment.sql new file mode 100644 index 00000000000..146b00f14c7 --- /dev/null +++ b/packages/db/src/migrations/0466_drop_project_environment.sql @@ -0,0 +1,2 @@ +ALTER TABLE "projects" DROP CONSTRAINT IF EXISTS "projects_environment_id_environments_id_fk";--> statement-breakpoint +ALTER TABLE "projects" DROP COLUMN IF EXISTS "environment_id"; diff --git a/packages/db/src/migrations/meta/0461_snapshot.json b/packages/db/src/migrations/meta/0466_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0461_snapshot.json rename to packages/db/src/migrations/meta/0466_snapshot.json index c68baee017d..8b570142d76 100644 --- a/packages/db/src/migrations/meta/0461_snapshot.json +++ b/packages/db/src/migrations/meta/0466_snapshot.json @@ -1,6 +1,6 @@ { - "id": "f409684f-c629-4648-9123-a5e1064308ef", - "prevId": "f93ccf18-7e86-4788-be77-b17430c86b2f", + "id": "593f81c4-2b4d-4fb4-a3e3-848b19bc9b0f", + "prevId": "38dbf2b9-da26-4c3a-b813-f37d0c19cba2", "version": "7", "dialect": "postgresql", "tables": { @@ -6233,7 +6233,7 @@ "name": "issuer", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "account_id": { "name": "account_id", @@ -6309,11 +6309,11 @@ } }, "indexes": { - "account_issuer_account_id_uq": { - "name": "account_issuer_account_id_uq", + "account_provider_account_id_uq": { + "name": "account_provider_account_id_uq", "columns": [ { - "expression": "issuer", + "expression": "provider_id", "isExpression": false, "asc": true, "nulls": "last" @@ -49107,6 +49107,93 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_lifecycle_resource_cursors": { + "name": "plugin_lifecycle_resource_cursors", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_through_id": { + "name": "acknowledged_through_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk": { + "name": "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk", + "tableFrom": "plugin_lifecycle_resource_cursors", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_lifecycle_resource_cursors_company_id_companies_id_fk": { + "name": "plugin_lifecycle_resource_cursors_company_id_companies_id_fk", + "tableFrom": "plugin_lifecycle_resource_cursors", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_lifecycle_resource_cursors_pk": { + "name": "plugin_lifecycle_resource_cursors_pk", + "columns": [ + "plugin_id", + "company_id", + "resource_type", + "resource_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_logs": { "name": "plugin_logs", "schema": "", @@ -59516,6 +59603,13 @@ "notNull": true, "default": "''" }, + "pending_reveal": { + "name": "pending_reveal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "allowed_actions": { "name": "allowed_actions", "type": "jsonb", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index efa85154e34..93aad29fed9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3249,6 +3249,13 @@ "when": 1794400732201, "tag": "0465_better_auth_account_identity", "breakpoints": true + }, + { + "idx": 466, + "version": "7", + "when": 1794408985337, + "tag": "0466_drop_project_environment", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 2302caf0768..10e320891bb 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -2,7 +2,6 @@ import { sql } from "drizzle-orm"; import { pgTable, uuid, text, timestamp, date, index, jsonb, check, uniqueIndex } from "drizzle-orm/pg-core"; import type { AgentEnvConfig } from "@paperclipai/shared"; import { companies } from "./companies.js"; -import { environments } from "./environments.js"; import { goals } from "./goals.js"; import { agents } from "./agents.js"; import { connectionGrants } from "./tool_access.js"; @@ -24,7 +23,6 @@ export const projects = pgTable( color: text("color"), icon: text("icon"), env: jsonb("env").$type(), - environmentId: uuid("environment_id").references(() => environments.id, { onDelete: "set null" }), gitAuthorizationGrantId: uuid("git_authorization_grant_id").references(() => connectionGrants.id, { onDelete: "set null" }), gitAuthorizedByUserId: text("git_authorized_by_user_id"), pauseReason: text("pause_reason"), diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 2a20b4d8772..ae2e718acd6 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -147,8 +147,6 @@ export interface Project { color: string | null; icon: string | null; env: AgentEnvConfig | null; - /** Project-owned execution target; null inherits the agent/instance default. */ - environmentId?: string | null; pauseReason: PauseReason | null; pausedAt: Date | null; executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index dc051327b2e..0257acc2940 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -119,7 +119,6 @@ const projectFields = { color: z.string().optional().nullable(), icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(), env: envConfigSchema.optional().nullable(), - environmentId: z.string().guid().optional().nullable(), executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(), archivedAt: z.string().datetime().optional().nullable(), }; @@ -137,7 +136,7 @@ export const createProjectSchema = z.object({ export type CreateProject = z.infer; export const updateProjectSchema = objectWithoutDefaults( - z.object({ ...projectFields, expectedStorageProviderPluginId: z.string().guid().nullable().optional() }), + z.object(projectFields), ).partial(); export type UpdateProject = z.infer; diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index 487080a56f3..c08a109f8c0 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -6,8 +6,6 @@ import { errorHandler } from "../middleware/index.js"; import { projectRoutes } from "../routes/projects.js"; import { issueRoutes } from "../routes/issues.js"; -const projectPermissions = vi.hoisted(() => ({ canWrite: true })); - const mockProjectService = vi.hoisted(() => ({ create: vi.fn(), getById: vi.fn(), @@ -65,7 +63,7 @@ vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), decide: vi.fn(async (input: { action: string }) => ({ - allowed: ["project:read", "issue:read"].includes(input.action) || (projectPermissions.canWrite && ["tasks:assign", "project:write_workspace"].includes(input.action)), + allowed: ["project:read", "issue:read"].includes(input.action), action: input.action, reason: ["project:read", "issue:read"].includes(input.action) ? "allow_explicit_grant" : "deny_missing_grant", explanation: ["project:read", "issue:read"].includes(input.action) ? "Allowed by test default." : "Missing permission.", @@ -185,7 +183,6 @@ describe("execution environment route guards", () => { }); beforeEach(() => { - projectPermissions.canWrite = true; mockProjectService.create.mockReset(); mockProjectService.getById.mockReset(); mockProjectService.update.mockReset(); @@ -214,17 +211,12 @@ describe("execution environment route guards", () => { mockLogActivity.mockReset(); }); - it("accepts sandbox environments on project create", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: sandboxEnvironmentId, - companyId: "company-1", - driver: "sandbox", - config: { provider: "fake-plugin" }, - }); + it("ignores a stale legacy policy environment on project create", async () => { + mockEnvironmentService.getById.mockResolvedValue(null); mockProjectService.create.mockResolvedValue({ id: "project-1", companyId: "company-1", - name: "Sandboxed Project", + name: "Legacy Project", status: "backlog", }); const app = createProjectApp(); @@ -232,35 +224,31 @@ describe("execution environment route guards", () => { const res = await request(app) .post("/api/companies/company-1/projects") .send({ - name: "Sandboxed Project", + name: "Legacy Project", executionWorkspacePolicy: { enabled: true, environmentId: sandboxEnvironmentId, }, }); - expect(res.status).not.toBe(422); + expect(res.status).toBe(201); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); expect(mockProjectService.create).toHaveBeenCalled(); }); - it("accepts sandbox environments on project update", async () => { + it("ignores a stale legacy policy environment on project update", async () => { mockProjectService.getById.mockResolvedValue({ id: "project-1", companyId: "company-1", - name: "Sandboxed Project", + name: "Legacy Project", status: "backlog", archivedAt: null, }); - mockEnvironmentService.getById.mockResolvedValue({ - id: sandboxEnvironmentId, - companyId: "company-1", - driver: "sandbox", - config: { provider: "fake-plugin" }, - }); + mockEnvironmentService.getById.mockResolvedValue(null); mockProjectService.update.mockResolvedValue({ id: "project-1", companyId: "company-1", - name: "Sandboxed Project", + name: "Legacy Project", status: "backlog", }); const app = createProjectApp(); @@ -274,18 +262,11 @@ describe("execution environment route guards", () => { }, }); - expect(res.status).not.toBe(422); + expect(res.status).toBe(200); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); expect(mockProjectService.update).toHaveBeenCalled(); }); - it.each([sandboxEnvironmentId, null])("rejects a project environment change or clear without workspace-write permission (%s)", async environmentId => { - projectPermissions.canWrite = false; - mockProjectService.getById.mockResolvedValue({ id: "project-1", companyId: "company-1", environmentId: "other-environment" }); - const res = await request(createProjectApp()).patch("/api/projects/project-1").send({ environmentId }); - expect(res.status).toBe(403); - expect(mockProjectService.update).not.toHaveBeenCalled(); - }); - it("accepts sandbox environments on issue create", async () => { mockEnvironmentService.getById.mockResolvedValue({ id: sandboxEnvironmentId, diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 9389d655ccc..e44ea327f3a 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -780,15 +780,6 @@ describe("operator default isolated execution workspaces", () => { }); }); -describe("project-owned environment", () => { - it("gives the project priority while keeping task trust and managed-only restrictions", () => { - const defaults = { agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env" }; - expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "project-env" })).toEqual({ environmentId: "project-env", source: "project" }); - expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "project-env", lowTrustIssueEnvironmentId: "task-sandbox" })).toEqual({ environmentId: "task-sandbox", source: "issue" }); - expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "local-env", managedSandboxOnly: true, managedSandboxEnvironmentId: "managed-env" })).toEqual({ environmentId: "managed-env", source: "managed" }); - }); -}); - describe("task-scoped low-trust environment", () => { it("uses the explicit phone-task sandbox while preserving ordinary agent defaults", () => { const defaults = {agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env"}; diff --git a/server/src/__tests__/plugin-workspaces.integration.test.ts b/server/src/__tests__/plugin-workspaces.integration.test.ts index cd4f49d68a1..372431d8711 100644 --- a/server/src/__tests__/plugin-workspaces.integration.test.ts +++ b/server/src/__tests__/plugin-workspaces.integration.test.ts @@ -590,7 +590,7 @@ const support = await getEmbeddedPostgresTestSupport(); svc.update(f.input.projectId, { executionWorkspacePolicy: { storageProviderPluginId: null }, }), - ).rejects.toThrow("stays attached"); + ).rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } }); await expect( svc.updateWorkspace(f.input.projectId, f.input.projectWorkspaceId, { repoUrl: "https://github.com/example/other.git", diff --git a/server/src/__tests__/project-environments.test.ts b/server/src/__tests__/project-environments.test.ts index 1f01ca95736..0e66fb9bb1c 100644 --- a/server/src/__tests__/project-environments.test.ts +++ b/server/src/__tests__/project-environments.test.ts @@ -1,14 +1,11 @@ -import { readFile } from "node:fs/promises"; 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 { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, projects, userCompanyPreferences } from "@paperclipai/db"; -import { projectEnvironmentOptions, resolveProjectEnvironment } from "../services/project-environments.js"; +import { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, userCompanyPreferences } from "@paperclipai/db"; +import { projectEnvironmentOptions, withDefaultProjectStorage } from "../services/project-environments.js"; import { assertProjectStorageEnvironment, projectStorageForEnvironment } from "../services/project-workspace-provider.js"; -import { environmentRunOrchestrator } from "../services/environment-run-orchestrator.js"; -import { environmentService } from "../services/environments.js"; import { issueService } from "../services/issues.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { applyDefaultIsolatedExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType } from "../services/execution-workspace-policy.js"; @@ -83,8 +80,8 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.sandbox.id); expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.computer.id); expect((await projectEnvironmentOptions(db, f.companyId, { agentId: f.input.agentId })).defaultEnvironmentId).toBe(f.computer.id); - expect(await resolveProjectEnvironment(db, f.companyId, null)).toEqual({ environmentId: f.sandbox.id, executionWorkspacePolicy: null }); - expect(await resolveProjectEnvironment(db, f.companyId, null, { userId: "person" })).toMatchObject({ executionWorkspacePolicy: { storageProviderPluginId: f.pluginId } }); + expect(await withDefaultProjectStorage(db, f.companyId, null)).toBeNull(); + expect(await withDefaultProjectStorage(db, f.companyId, null, { userId: "person" })).toMatchObject({ storageProviderPluginId: f.pluginId }); await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, f.input.agentId)); expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.sandbox.id); await db.update(instanceSettings).set({ defaultEnvironmentId: null }); @@ -101,69 +98,36 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { const computerProject = await svc.create(f.companyId, { name: "Computer project" }, { environmentId: f.computer.id }); expect(computerProject.workspaceProvider?.pluginId).toBe(f.pluginId); const explicit = { enabled: true, storageProviderPluginId: null }; - expect(await resolveProjectEnvironment(db, f.companyId, explicit)).toEqual({ environmentId: null, executionWorkspacePolicy: explicit }); - await expect(resolveProjectEnvironment(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 }); + expect(await withDefaultProjectStorage(db, f.companyId, explicit)).toEqual(explicit); + await expect(withDefaultProjectStorage(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 }); await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id }); expect((await svc.getById(sandboxProject.id))?.workspaceProvider?.pluginId).toBeNull(); }); - it("keeps environment changes on one project and snapshots creation defaults", async () => { + it("saves storage, not a run environment, and keeps that storage after creation", async () => { const f = await fixture(); const svc = projectService(db); - const first = await svc.create(f.companyId, { name: "First project" }); - const second = await svc.create(f.companyId, { name: "Second project" }); - expect(first.environmentId).toBe(f.sandbox.id); - expect(second.environmentId).toBe(f.sandbox.id); - await svc.update(first.id, { environmentId: f.ssh.id, expectedStorageProviderPluginId: null }); - await db.update(instanceSettings).set({ defaultEnvironmentId: f.local.id }); - expect((await svc.getById(first.id))?.environmentId).toBe(f.ssh.id); - expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id); - expect((await svc.getById(first.id))?.executionWorkspacePolicy).toBeNull(); - expect((await db.select().from(agents).where(eq(agents.id, f.input.agentId)))[0].defaultEnvironmentId).toBeNull(); - expect((await environmentService(db).getDeleteBlastRadius(f.ssh.id))?.staticReferences.projectSelectionCount).toBe(1); - expect(await svc.clearExecutionWorkspaceEnvironmentSelection(f.companyId, f.ssh.id)).toBe(1); - expect((await svc.getById(first.id))?.environmentId).toBeNull(); - expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id); + const core = await svc.create(f.companyId, { name: "Core project" }, { environmentId: f.ssh.id, expectedStorageProviderPluginId: null }); + const managed = await svc.create(f.companyId, { name: "Managed project" }, { environmentId: f.computer.id }); + expect(core).not.toHaveProperty("environmentId"); + expect(core.workspaceProvider?.pluginId).toBeNull(); + expect(managed.workspaceProvider?.pluginId).toBe(f.pluginId); + await expect(svc.update(core.id, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })) + .rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } }); + await expect(svc.update(managed.id, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })) + .rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } }); + expect((await svc.getById(core.id))?.workspaceProvider?.pluginId).toBeNull(); + expect((await svc.getById(managed.id))?.workspaceProvider?.pluginId).toBe(f.pluginId); }); - it.each([{ enabled: true }, null])("preserves the storage owner before validating a combined environment/policy edit (%j)", async policy => { + it("drops a legacy policy environment instead of saving it", async () => { const f = await fixture(); const svc = projectService(db); - const project = await svc.create(f.companyId, { name: "Managed project" }, { environmentId: f.computer.id }); - await expect(svc.update(project.id, { environmentId: f.local.id, executionWorkspacePolicy: policy })) - .rejects.toMatchObject({ status: 409 }); - const saved = await svc.getById(project.id); - expect(saved?.environmentId).toBe(f.computer.id); - expect(saved?.executionWorkspacePolicy?.storageProviderPluginId).toBe(f.pluginId); - }); - - it.each([false, true])("migrates a saved legacy project selection without changing its checkout policy (uppercase=%s)", async uppercase => { - const f = await fixture(); - const migration = await readFile(new URL("../../../packages/db/src/migrations/0464_project_environment.sql", import.meta.url), "utf8"); - const project = await projectService(db).create(f.companyId, { name: "Existing project" }); - const [target] = await db.insert(environments).values({ id: "abcdefab-cdef-4abc-8def-abcdefabcdef", name: "Legacy target", driver: "ssh", config: { host: "legacy.example.test" } }).returning(); - await expect(db.transaction(async tx => { - await tx.update(projects).set({ executionWorkspacePolicy: { enabled: false, environmentId: uppercase ? target.id.toUpperCase() : target.id } }).where(eq(projects.id, project.id)); - await tx.execute(sql`alter table projects drop column environment_id`); - for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) { - await tx.execute(sql.raw(statement)); - } - const [saved] = await tx.select().from(projects).where(eq(projects.id, project.id)); - expect(saved.environmentId).toBe(target.id); - expect(saved.executionWorkspacePolicy).toEqual({ enabled: false }); - throw new Error("rollback migration fixture"); - })).rejects.toThrow("rollback migration fixture"); - }); - - it("accepts legacy project policy environment edits without changing other projects", async () => { - const f = await fixture(); - const svc = projectService(db); - const first = await svc.create(f.companyId, { name: "Legacy editor" }); - const second = await svc.create(f.companyId, { name: "Other project" }); - const saved = await svc.update(first.id, { executionWorkspacePolicy: { enabled: false, environmentId: f.ssh.id.toUpperCase() } }); - expect(saved?.environmentId).toBe(f.ssh.id); - expect(saved?.executionWorkspacePolicy).toEqual({ enabled: false }); - expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id); + const created = await svc.create(f.companyId, { name: "Legacy create", executionWorkspacePolicy: { enabled: false, environmentId: f.ssh.id } }); + expect(created.executionWorkspacePolicy).toEqual({ enabled: false }); + const updated = await svc.update(created.id, { executionWorkspacePolicy: { enabled: false, environmentId: f.local.id } }); + expect(updated?.executionWorkspacePolicy).toEqual({ enabled: false }); + expect(updated).not.toHaveProperty("environmentId"); }); it("preserves inherited task isolation when checking the observed core storage owner", async () => { @@ -198,15 +162,12 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning(); await db.insert(builtInManagedResources).values({ companyId: otherCompany.id, bundleKey: "test", resourceKind: "environment", resourceKey: "environment", resourceId: f.ssh.id, stockVersion: "1", stockHash: "test" }); - await expect(environmentRunOrchestrator(db).resolveEnvironment({ companyId: f.companyId, selectedEnvironmentId: f.ssh.id, - localEnvironmentId: f.local.id })).rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } }); - await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.local.id)); await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, f.pluginId)); const options = await projectEnvironmentOptions(db, f.companyId); expect(options.groups.flatMap(group => group.environments).map(value => value.id)).toEqual([f.sandbox.id]); for (const environmentId of [f.local.id, f.ssh.id, f.computer.id, randomUUID()]) { - await expect(resolveProjectEnvironment(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 }); + await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 }); } }); @@ -214,10 +175,10 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { const f = await fixture(); await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id }); await db.update(plugins).set({ status: "error" }).where(eq(plugins.id, f.pluginId)); - expect(await resolveProjectEnvironment(db, f.companyId, null)).toMatchObject({ executionWorkspacePolicy: { storageProviderPluginId: f.pluginId } }); + expect(await withDefaultProjectStorage(db, f.companyId, null)).toMatchObject({ storageProviderPluginId: f.pluginId }); await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id)); expect((await projectEnvironmentOptions(db, f.companyId)).error).toBeTruthy(); - await expect(resolveProjectEnvironment(db, f.companyId, null)).rejects.toMatchObject({ status: 409 }); + await expect(withDefaultProjectStorage(db, f.companyId, null)).rejects.toMatchObject({ status: 409 }); }); it.each(["instance", "primary"] as const)("creates repository-free private tasks despite an archived %s default", async source => { @@ -251,7 +212,7 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { await db.update(instanceSettings).set({ general: { executionMode: "kubernetes" } }); const forced = await projectEnvironmentOptions(db, f.companyId); expect(forced.groups).toEqual([{ storageProviderPluginId: null, environments: [{ id: f.sandbox.id, name: f.sandbox.name }] }]); - await expect(resolveProjectEnvironment(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 }); + await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 }); }); it("exposes safe groups and saves a validated choice through the normal API", async () => { @@ -261,8 +222,6 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { result.use((req, _res, next) => { req.actor = actor; next(); }); result.use("/api", projectRoutes(db)); result.use(errorHandler); return result; } - await db.insert(authUsers).values({ id: "person", name: "Person", email: "person@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing(); - await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "person", status: "active", membershipRole: "member" }); const board = app({ type: "board", userId: "person", source: "session", companyIds: [f.companyId] }); const url = `/api/companies/${f.companyId}`; const options = await request(board).get(`${url}/project-environments`); @@ -271,20 +230,7 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { const created = await request(board).post(`${url}/projects`).send({ name: "Selected", environmentId: f.computer.id }); expect(created.status).toBe(201); expect(created.body.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId }); - expect(created.body.environmentId).toBe(f.computer.id); - const first = await request(board).post(`${url}/projects`).send({ name: "First core project" }); - const second = await request(board).post(`${url}/projects`).send({ name: "Second core project" }); - const updateUrl = `/api/projects/${first.body.id}`; - const saved = await request(board).patch(updateUrl).send({ environmentId: f.ssh.id, expectedStorageProviderPluginId: null }); - expect(saved.status).toBe(200); - expect(saved.body.environmentId).toBe(f.ssh.id); - expect((await request(board).get(`/api/projects/${second.body.id}`)).body.environmentId).toBe(f.sandbox.id); - expect((await db.select().from(instanceSettings))[0].defaultEnvironmentId).toBe(f.sandbox.id); - expect((await request(app({ type: "none" })).patch(updateUrl).send({ environmentId: f.local.id })).status).toBe(404); - expect((await request(app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() })) - .patch(updateUrl).send({ environmentId: f.local.id })).status).toBe(404); - expect((await request(board).patch(updateUrl).send({ environmentId: f.computer.id, expectedStorageProviderPluginId: null })).status).toBe(409); - expect((await projectService(db).getById(first.body.id))?.environmentId).toBe(f.ssh.id); + expect(created.body).not.toHaveProperty("environmentId"); const denied = app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() }); expect((await request(denied).get(`${url}/project-environments`)).status).toBe(403); expect((await request(app({ type: "none" })).get(`${url}/project-environments`)).status).toBe(401); diff --git a/server/src/__tests__/project-workspace-provider.integration.test.ts b/server/src/__tests__/project-workspace-provider.integration.test.ts index fe9279b02f0..b5e2e23244d 100644 --- a/server/src/__tests__/project-workspace-provider.integration.test.ts +++ b/server/src/__tests__/project-workspace-provider.integration.test.ts @@ -114,18 +114,24 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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"); + .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("lets a Core project move to a storage plugin, and then keeps it there", async () => { + 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); - expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))!.workspaceProvider) - .toMatchObject({ source: "project", pluginId: f.pluginId }); - await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).rejects.toThrow("stays attached"); + 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 () => { diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 7b667384f67..0e2acbc95c1 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -54,8 +54,6 @@ import { import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; import { appendWithCap } from "../adapters/utils.js"; -import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; -import { environmentService } from "../services/environments.js"; import { secretService } from "../services/secrets.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; @@ -87,7 +85,6 @@ export function projectRoutes(db: Db) { enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true, }); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; - const environmentsSvc = environmentService(db); /** * Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project @@ -114,21 +111,6 @@ export function projectRoutes(db: Db) { ); } - async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) { - if (environmentId === undefined || environmentId === null) return; - await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { - allowedDrivers: ["local", "ssh", "sandbox"], - }); - } - - function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined { - if (!policy || typeof policy !== "object" || !("environmentId" in policy)) { - return undefined; - } - const environmentId = (policy as { environmentId?: unknown }).environmentId; - return typeof environmentId === "string" || environmentId === null ? environmentId : undefined; - } - async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; const requestedCompanyId = @@ -173,9 +155,9 @@ export function projectRoutes(db: Db) { return false; } - async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id?: string; companyId: string }) { + async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id: string; companyId: string }) { const assigneeAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null; - const decision = await access.decide({ actor: req.actor, action: project.id ? "project:write_workspace" : "tasks:assign", + const decision = await access.decide({ actor: req.actor, action: "project:write_workspace", resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId }, scope: { projectId: project.id, assigneeAgentId } }); if (decision.allowed) return true; @@ -401,12 +383,6 @@ export function projectRoutes(db: Db) { projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null; const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId ? await projectToolContext(db, req.actor, true) : null; - if ((environmentId || readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy)) - && !(await assertProjectWorkspaceWriteAllowed(req, res, { companyId }))) return; - await assertProjectEnvironmentSelection( - companyId, - readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy), - ); assertNoAgentHostWorkspaceCommandMutation( req, [ @@ -524,9 +500,6 @@ export function projectRoutes(db: Db) { if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; const body = { ...req.body }; - const nextEnvironmentId = body.environmentId !== undefined ? body.environmentId : readProjectPolicyEnvironmentId(body.executionWorkspacePolicy); - const environmentChanged = nextEnvironmentId !== undefined && nextEnvironmentId !== (existing.environmentId ?? null); - if (environmentChanged && !(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return; if (body.visibility !== undefined) { if (!(await assertCanManageProjectPrivacy(req, res, existing))) return; } @@ -535,9 +508,6 @@ export function projectRoutes(db: Db) { req, collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), ); - if (environmentChanged && body.environmentId === undefined) { - await assertProjectEnvironmentSelection(existing.companyId, nextEnvironmentId); - } if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 6de1229c272..29b73fc9eb9 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -11,7 +11,7 @@ import { withEnvironmentLeaseController } from "./environment-lease-controller.j import { randomUUID } from "node:crypto"; import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; -import { and, asc, desc, eq, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, ne, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -1390,7 +1390,7 @@ export function environmentService(db: Db) { db .select({ count: sql`count(*)::int` }) .from(projects) - .where(or(eq(projects.environmentId, id), sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`)), + .where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`), db .select({ count: sql`count(*)::int` }) .from(companySecretBindings) diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 3b433f9c027..4a7f9c9fa33 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -274,7 +274,6 @@ export function selectEnvironmentExecutionWorkspaceSettings( export type ExecutionWorkspaceEnvironmentSource = | "issue" - | "project" | "run" | "agent" | "instance" @@ -300,7 +299,6 @@ export class ManagedSandboxUnavailableError extends Error { export function resolveExecutionWorkspaceEnvironmentId(input: { /** Explicit operator-selected sandbox for a task with a low-trust boundary. */ lowTrustIssueEnvironmentId?: string | null; - projectEnvironmentId?: string | null; agentDefaultEnvironmentId: string | null; instanceDefaultEnvironmentId: string | null; localDefaultEnvironmentId: string; @@ -319,9 +317,6 @@ export function resolveExecutionWorkspaceEnvironmentId(input: { if (input.lowTrustIssueEnvironmentId) { return {environmentId: input.lowTrustIssueEnvironmentId, source: "issue"}; } - if (input.projectEnvironmentId) { - return { environmentId: input.projectEnvironmentId, source: "project" }; - } if (input.agentDefaultEnvironmentId) { return { environmentId: input.agentDefaultEnvironmentId, diff --git a/server/src/services/heartbeat/environment-selection.test.ts b/server/src/services/heartbeat/environment-selection.test.ts index 730233aa512..d8e252c832c 100644 --- a/server/src/services/heartbeat/environment-selection.test.ts +++ b/server/src/services/heartbeat/environment-selection.test.ts @@ -82,40 +82,24 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary", return { input, issue, project, workspace, recover }; } - it("uses each project's saved environment before the shared agent and instance defaults", async () => { + it("places a new run by the agent default, not by its project", async () => { const f = await fixture(); const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId); - const [target] = await db.insert(environments).values({ name: `Project host ${randomUUID()}`, driver: "ssh", config: { host: "project.example.test" } }).returning(); - const [other] = await db.insert(projects).values({ companyId: f.input.agent.companyId, name: "Other project", environmentId: local.id }).returning(); - await db.update(projects).set({ environmentId: target.id }).where(eq(projects.id, f.project.id)); + const [other] = await db.insert(projects).values({ companyId: f.input.agent.companyId, name: "Other project" }).returning(); f.input.agent.defaultEnvironmentId = local.id; f.input.policy.isolatedWorkspacesEnabled = false; - expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: target.id, source: "project" }); + expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "agent" }); f.input.task.executionProjectId = other.id; - f.input.task.issueRef!.projectId = other.id; - expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "project" }); - f.input.task.executionProjectId = f.project.id; - f.input.task.issueRef!.projectId = f.project.id; - await db.update(projects).set({ environmentId: null }).where(eq(projects.id, f.project.id)); expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "agent" }); }); - it("keeps task project placement when files come from another project", async () => { - const f = await fixture(); - const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId); - const [source] = await db.insert(projects).values({ companyId: f.project.companyId, name: "File source" }).returning(); - await db.update(projects).set({ environmentId: local.id }).where(eq(projects.id, f.project.id)); - f.input.task.executionProjectId = source.id; - expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "project" }); - }); - - it("restores the admitted environment after a project edit and rejects a foreign lease", async () => { + it("restores the admitted environment after a default change and rejects a foreign lease", async () => { const f = await fixture(); const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId); const [next] = await db.insert(environments).values({ name: `Next target ${randomUUID()}`, driver: "ssh", config: { host: "next.example.test" } }).returning(); const saved = await f.workspace(); f.recover(saved.id); - await db.update(projects).set({ environmentId: next.id }).where(eq(projects.id, f.project.id)); + f.input.agent.defaultEnvironmentId = next.id; const [lease] = await db.insert(environmentLeases).values({ companyId: f.project.companyId, environmentId: local.id, heartbeatRunId: f.input.run.id, issueId: f.issue.id, executionWorkspaceId: saved.id, metadata: { agentId: f.input.agent.id } }).returning(); f.input.run.contextSnapshot = { paperclipEnvironment: { id: local.id, leaseId: lease.id } }; diff --git a/server/src/services/heartbeat/environment-selection.ts b/server/src/services/heartbeat/environment-selection.ts index 58c4a46728a..ab0581c87f0 100644 --- a/server/src/services/heartbeat/environment-selection.ts +++ b/server/src/services/heartbeat/environment-selection.ts @@ -1,6 +1,6 @@ import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js"; import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js"; -import { heartbeatRuns, issues, projects, type Db, type agents } from "@paperclipai/db"; +import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db"; import { and, eq } from "drizzle-orm"; import { readGitCacheExpiration } from "../git-workspace-restoration.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings, ProjectExecutionWorkspacePolicy } from "@paperclipai/shared"; @@ -47,7 +47,7 @@ export interface HeartbeatEnvironmentSelectionInput { "id" | "companyId" | "adapterType" | "adapterConfig" | "defaultEnvironmentId">; task: { freshWorkspace?: import("../fresh-workspace-continuation.js").ContinuationReadiness | null; - issueRef: Pick & { projectId?: string | null } | null; + issueRef: Pick | null; issueContext: Pick | null; issueId: string | null; context: Record; @@ -203,12 +203,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron const managedSandboxEnvironment = managedSandboxOnly && !providerApiPlacement ? await environmentsSvc.findManagedSandboxEnvironment(agent.companyId) : null; - const placementProjectId = issueRef ? issueRef.projectId ?? null : executionProjectId; - const [projectEnvironment] = placementProjectId && !controllerOnly && !persistedNativeExecutionInput - ? await db.select({ environmentId: projects.environmentId }).from(projects) - .where(and(eq(projects.id, placementProjectId), eq(projects.companyId, agent.companyId))) - : []; - // A project edit affects new admissions, never a surviving provider process. const savedEnvironment = parseObject(parseObject(run.contextSnapshot).paperclipEnvironment); const admittedLeaseId = persistedNativeExecutionInput && !controllerOnly ? readNonEmptyString(savedEnvironment.leaseId) ?? readNativeWorkspaceSyncReference(persistedRunnerProfile.nativeWorkspaceSync)?.leaseId @@ -227,7 +221,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron : resolveExecutionWorkspaceEnvironmentId({ lowTrustIssueEnvironmentId: trustPreset.kind === "low_trust_review" ? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings, {includeEnvironmentId: true})?.environmentId : null, - projectEnvironmentId: projectEnvironment?.environmentId, agentDefaultEnvironmentId: agent.defaultEnvironmentId, instanceDefaultEnvironmentId: resolvedInstanceSettings.defaultEnvironmentId ?? null, diff --git a/server/src/services/project-environments.ts b/server/src/services/project-environments.ts index 9c84bc2fc30..3115a5da36c 100644 --- a/server/src/services/project-environments.ts +++ b/server/src/services/project-environments.ts @@ -70,9 +70,9 @@ export async function projectEnvironmentOptions(db: Db, companyId: string, actor }; } -export async function resolveProjectEnvironment(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) { +export async function withDefaultProjectStorage(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) { const policy = parseProjectExecutionWorkspacePolicy(rawPolicy); - if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return { environmentId: null, executionWorkspacePolicy: rawPolicy }; + if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return rawPolicy; const options = await projectEnvironmentOptions(db, companyId, actor); const environmentId = actor.environmentId?.toLowerCase() ?? options.defaultEnvironmentId; const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId)); @@ -85,7 +85,7 @@ export async function resolveProjectEnvironment(db: Db, companyId: string, rawPo if (policy?.storageProviderPluginId !== undefined && policy.storageProviderPluginId !== pluginId) { throw conflict("The selected environment cannot use the requested project workspace storage.", { code: "project_environment_incompatible" }); } - return { environmentId: environmentId ?? null, executionWorkspacePolicy: pluginId || policy?.storageProviderPluginId !== undefined + return pluginId || policy?.storageProviderPluginId !== undefined ? { enabled: true, ...policy, storageProviderPluginId: pluginId } - : rawPolicy }; + : rawPolicy; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index e007bde811b..48edc2ed5c5 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,6 +1,6 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js"; -import { resolveProjectEnvironment, type ProjectCreationEnvironment } from "./project-environments.js"; +import { withDefaultProjectStorage, type ProjectCreationEnvironment } from "./project-environments.js"; import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js"; import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; @@ -59,6 +59,12 @@ function legacyProjectEnvironmentId(policy: Record | null | und return typeof id === "string" || id === null ? id : undefined; } +function withoutLegacyProjectEnvironment | null | undefined>(policy: T): T { + if (legacyProjectEnvironmentId(policy) === undefined) return policy; + const normalized = parseProjectExecutionWorkspacePolicy(policy); + return (normalized ? { ...normalized } : null) as T; +} + type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; @@ -681,14 +687,8 @@ 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 legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy); - const workspacePolicy = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy); - const selected = await resolveProjectEnvironment(db, companyId, - legacyEnvironmentId !== undefined ? workspacePolicy : projectData.executionWorkspacePolicy ?? null, { - ...environment, environmentId: environment.environmentId ?? projectData.environmentId ?? legacyEnvironmentId ?? undefined, - }); - const executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy; - projectData.environmentId = selected.environmentId; + const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, + withoutLegacyProjectEnvironment(projectData.executionWorkspacePolicy) ?? null, environment) as typeof projectData.executionWorkspacePolicy; return db.transaction(async (tx) => { const txDb = tx as unknown as Db; @@ -984,48 +984,28 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool update: async ( id: string, - data: Partial & { goalIds?: string[]; expectedStorageProviderPluginId?: string | null }, + data: Partial & { goalIds?: string[] }, ): Promise => db.transaction(async tx => { const db = tx as unknown as Db; - const { goalIds: inputGoalIds, expectedStorageProviderPluginId, ...projectData } = data; + const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); const existingProject = await db - .select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) .where(eq(projects.id, id)).for("update") .then((rows) => rows[0] ?? null); if (!existingProject) return null; - const legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy); - if (projectData.environmentId === undefined && legacyEnvironmentId !== undefined) { - projectData.environmentId = legacyEnvironmentId; - } - if (legacyEnvironmentId !== undefined) { - const normalized = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy); - projectData.executionWorkspacePolicy = normalized ? { ...normalized } : null; - } + projectData.executionWorkspacePolicy = withoutLegacyProjectEnvironment(projectData.executionWorkspacePolicy); const currentStorage = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId; - if (currentStorage && projectData.executionWorkspacePolicy !== undefined) { + if (projectData.executionWorkspacePolicy !== undefined) { const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId; - if (requestedStorage !== undefined && requestedStorage !== currentStorage) { - throw conflict("A plugin-backed project stays attached to its storage plugin"); + if (requestedStorage !== undefined && requestedStorage !== (currentStorage ?? null)) { + throw conflict("A project stays on the workspace storage chosen when it was created", { code: "project_storage_locked" }); } - if (requestedStorage === undefined) { + if (requestedStorage === undefined && currentStorage) { projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: currentStorage }; } } - const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId; - const storageChanged = requestedStorage !== undefined && requestedStorage !== currentStorage; - const selectedEnvironmentId = projectData.environmentId === undefined && storageChanged - ? existingProject.environmentId : projectData.environmentId; - if (selectedEnvironmentId && (selectedEnvironmentId !== existingProject.environmentId || storageChanged || expectedStorageProviderPluginId !== undefined)) { - const selected = await resolveProjectEnvironment(db, existingProject.companyId, - projectData.executionWorkspacePolicy ?? existingProject.executionWorkspacePolicy, - { environmentId: selectedEnvironmentId, expectedStorageProviderPluginId }); - projectData.executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy; - } - if (!currentStorage && requestedStorage) { - await retireRemovedPluginWorkspaceDependents(db, id); - } if (ids && ids.length > 0) { await assertGoalsBelongToCompany(db, existingProject.companyId, ids); } @@ -1101,7 +1081,6 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool const rows = await db .select({ id: projects.id, - environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy, }) .from(projects) @@ -1109,12 +1088,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool let cleared = 0; for (const row of rows) { - const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy); - if (row.environmentId !== environmentId && legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue; + if (legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue; await projectService(db).update(row.id, { - environmentId: null, - ...(legacyProjectEnvironmentId(row.executionWorkspacePolicy) === environmentId ? { executionWorkspacePolicy: policy ? { ...policy } : null } : {}), + executionWorkspacePolicy: withoutLegacyProjectEnvironment(row.executionWorkspacePolicy), }); cleared += 1; } diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index 105248e26d5..0244d33d848 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -406,7 +406,7 @@ "packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec", "packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358", "packages/shared/src/types/plugin.ts": "36e0f50d97f96532cb1fbdf3223368d92a7e9b1e", - "packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf", + "packages/shared/src/types/project.ts": "ae2e718acd652e362cf1ef91e69aad96d12042fe", "packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e", "packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8", "packages/shared/src/types/routine.ts": "e5e5a93d63233029b9bf258558ad155677a4eca7", @@ -487,7 +487,7 @@ "packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53", "packages/shared/src/validators/plugin.test.ts": "23bc443850d46d815877a71a44625fd42aebb00a", "packages/shared/src/validators/plugin.ts": "054de474635e080073e85413211cca4b3eb8681b", - "packages/shared/src/validators/project.ts": "dc051327b2eaf4a28288a84e21c1f3baab906bc9", + "packages/shared/src/validators/project.ts": "0257acc29400f438aefc0a657250d8f28ec60838", "packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82", "packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd", "packages/shared/src/validators/routine.test.ts": "1501907ade577c1ab4c4b3098397e5d080446878", diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 41e9bb4157f..09d72ac7762 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -201,7 +201,6 @@ describe("IssueWorkspaceCard", () => { issue={createIssue()} project={{ id: "project-1", - environmentId: "env-project", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", @@ -263,7 +262,6 @@ describe("IssueWorkspaceCard", () => { issue={createIssue()} project={{ id: "project-1", - environmentId: "env-project", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index ff4853c5123..b97a4208968 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -165,7 +165,6 @@ interface IssueWorkspaceCardProps { currentExecutionWorkspace?: ExecutionWorkspace | null; }; project: { - environmentId?: string | null; id: string; executionWorkspacePolicy?: { enabled?: boolean; @@ -254,15 +253,11 @@ export function IssueWorkspaceCard({ const [draftSelection, setDraftSelection] = useState(currentSelection); const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); - const projectEnvironmentId = environmentsEnabled - ? project?.environmentId ?? null - : null; const currentReusableEnvironmentId = selectedReusableExecutionWorkspace?.config?.environmentId ?? null; const currentEnvironmentId = environmentsEnabled ? ( (currentSelection === "reuse_existing" && currentReusableEnvironmentId) ?? workspace?.config?.environmentId - ?? projectEnvironmentId ) : null; const currentEnvironment = @@ -411,8 +406,6 @@ export function IssueWorkspaceCard({ Environment: {currentEnvironment?.name ?? currentEnvironmentId} {currentSelection === "reuse_existing" && currentReusableEnvironmentId === currentEnvironmentId ? " · reused workspace" - : !issue.executionWorkspaceSettings?.environmentId && projectEnvironmentId === currentEnvironmentId - ? " · project default" : null} )} diff --git a/ui/src/components/ProjectProperties.concurrency.test.tsx b/ui/src/components/ProjectProperties.concurrency.test.tsx index 8035b3a5f79..2e2e0cb8ac7 100644 --- a/ui/src/components/ProjectProperties.concurrency.test.tsx +++ b/ui/src/components/ProjectProperties.concurrency.test.tsx @@ -20,7 +20,12 @@ function act(callback: () => void) { const noop = vi.hoisted(() => () => undefined); -vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } })); +const environmentOptions = vi.hoisted(() => ({ groups: [ + { storageProviderPluginId: null, environments: [{ id: "local", name: "Local" }, { id: "sandbox", name: "Paperclip Sandbox" }] }, + { storageProviderPluginId: "provider-one", environments: [{ id: "computer", name: "Agent computers" }] }, +], defaultEnvironmentId: "local", error: null })); +vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn(), + environmentOptions: vi.fn(async () => environmentOptions) } })); vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } })); vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } })); vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } })); @@ -57,6 +62,7 @@ function primedClient(hiddenSettings: string[] = []) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); client.setQueryData(queryKeys.health, { hiddenSettings }); client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true }); + client.setQueryData(queryKeys.projects.environments("company-1"), environmentOptions); return client; } @@ -140,31 +146,44 @@ it("hides project isolation controls without rewriting its policy", () => { }); -describe("ProjectProperties — workspace storage selection", () => { +describe("ProjectProperties — workspace storage availability", () => { const inherited = { 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('select[aria-label="Workspace storage provider"]')!; } - it("shows an unattached project on Core storage and offers each plugin", () => { - render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn()); - 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."); + const core = { source: "core" as const, pluginId: null, displayName: "Core / local", error: null, effectivePolicy: null, providers: inherited.providers }; + function note() { return container.querySelector('[data-testid="project-storage-availability"]'); } + it("offers no storage or environment control", () => { + render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: core }), vi.fn()); + expect(container.querySelector('select[aria-label="Workspace storage provider"]')).toBeNull(); + expect(container.querySelector('select[id^="project-environment-"]')).toBeNull(); }); - it("saves a move to a plugin while preserving explicit settings", () => { - const onFieldUpdate = vi.fn(); - 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("hides the note when no storage plugin is installed", () => { + render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: { ...core, providers: [] } }), vi.fn()); + expect(note()).toBeNull(); + expect(container.textContent).not.toContain("Available in"); }); - 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("lists the environments that share a core project's storage when a plugin is installed", () => { + render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: core }), vi.fn()); + expect(note()?.textContent).toContain("Available in"); + expect(note()?.textContent).toContain("Local, Paperclip Sandbox"); + expect(note()?.textContent).not.toContain("Agent computers"); + expect(note()?.textContent).toContain("Core / local workspace storage was chosen when this project was created"); + }); + it("lists the plugin's environments for a plugin-backed project", () => { + render(makeProject({ companyId: "company-1", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" }, + workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn()); + expect(note()?.textContent).toContain("Agent computers"); + expect(note()?.textContent).not.toContain("Paperclip Sandbox"); + expect(note()?.textContent).toContain("Managed storage workspace storage"); + }); + it("reports an uninstalled storage plugin instead of hiding the note", () => { + render(makeProject({ companyId: "company-1", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" }, + workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one", displayName: "Unavailable provider", + providers: [], error: "The workspace storage plugin is unavailable" } }), vi.fn()); + expect(note()?.querySelector('[role="alert"]')?.textContent).toBe("Storage plugin unavailable"); + expect(note()?.querySelectorAll('[role="alert"]')).toHaveLength(1); }); it("does not copy inherited mode, strategy, or provider when another setting changes", () => { const onFieldUpdate = vi.fn(); diff --git a/ui/src/components/ProjectProperties.environment.test.tsx b/ui/src/components/ProjectProperties.environment.test.tsx deleted file mode 100644 index baf63a17a2d..00000000000 --- a/ui/src/components/ProjectProperties.environment.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Project } from "@paperclipai/shared"; -import { useState } from "react"; -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { projectsApi } from "../api/projects"; -import { ProjectProperties } from "./ProjectProperties"; -import { TooltipProvider } from "./ui/tooltip"; -import { queryKeys } from "../lib/queryKeys"; - -(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -const options = vi.hoisted(() => ({ groups: [ - { storageProviderPluginId: null, environments: [{ id: "local", name: "Local" }, { id: "ssh", name: "Build host" }] }, - { storageProviderPluginId: "plugin", environments: [{ id: "computer", name: "Computers" }] }, -], defaultEnvironmentId: "local", error: null })); -vi.mock("../api/projects", () => ({ projectsApi: { environmentOptions: vi.fn(async () => options) } })); -vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn(async () => []), listUserSecretDefinitions: vi.fn(async () => []) } })); -vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn(async () => ({ enableEnvironments: true, enableIsolatedWorkspaces: false })) } })); -vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company", companies: [] }) })); -vi.mock("./ProjectRepositories", () => ({ ProjectRepositories: () => null })); -vi.mock("./ProjectGitAuthorization", () => ({ ProjectGitAuthorization: () => null })); -vi.mock("./ProjectAccessMembers", () => ({ ProjectAccessMembers: () => null })); -vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null })); -vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null })); - -const update = vi.fn(); -let container: HTMLDivElement, root: Root; -beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); -afterEach(() => { flushSync(() => root.unmount()); container.remove(); vi.clearAllMocks(); }); - -function makeProject(id: string): Project { - return { id, companyId: "company", name: id, urlKey: id, status: "planned", goalIds: [], goals: [], env: null, - environmentId: "local", executionWorkspacePolicy: null, workspaces: [], primaryWorkspace: null, - workspaceProvider: { source: "core", pluginId: null, displayName: "Core / local", providers: [{ id: "plugin", displayName: "Managed storage" }], effectivePolicy: null, error: null }, - codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null }, - } as unknown as Project; -} - -function renderProjects(seedOptions = true) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); - client.setQueryData(queryKeys.health, { hiddenSettings: [] }); - client.setQueryData(queryKeys.instance.experimentalSettings, { enableEnvironments: true, enableIsolatedWorkspaces: false }); - if (seedOptions) client.setQueryData(queryKeys.projects.environments("company"), options); - function Projects() { - const [projects, setProjects] = useState([makeProject("first"), makeProject("second")]); - return projects.map(project =>
- { - update(project.id, field, data); - setProjects(previous => previous.map(value => value.id === project.id ? { ...value, ...data } as Project : value)); - }} /> -
); - } - flushSync(() => root.render()); - return client; -} - -it("keeps environment selection on its project with isolation disabled and a shared query cache", () => { - const client = renderProjects(); - const first = container.querySelector('[data-project="first"] select[id^="project-environment-"]')!; - const second = container.querySelector('[data-project="second"] select[id^="project-environment-"]')!; - expect(first.value).toBe("local"); - expect(second.value).toBe("local"); - expect([...first.options].map(option => option.value)).not.toContain("computer"); - flushSync(() => { first.value = "ssh"; first.dispatchEvent(new Event("change", { bubbles: true })); }); - expect(update).toHaveBeenCalledExactlyOnceWith("first", "execution_workspace_environment", { environmentId: "ssh", expectedStorageProviderPluginId: null }); - expect(first.value).toBe("ssh"); - expect(second.value).toBe("local"); - expect(client.getQueryData(queryKeys.projects.environments("company"))).toEqual(options); - flushSync(() => { first.value = ""; first.dispatchEvent(new Event("change", { bubbles: true })); }); - expect(update).toHaveBeenLastCalledWith("first", "execution_workspace_environment", { environmentId: null }); - expect(second.value).toBe("local"); -}); - -it("changes storage and the compatible environment together on only the selected project", () => { - renderProjects(); - const firstStorage = container.querySelector('[data-project="first"] select#workspace-storage-provider')!; - flushSync(() => { firstStorage.value = "plugin"; firstStorage.dispatchEvent(new Event("change", { bubbles: true })); }); - expect(update).toHaveBeenCalledExactlyOnceWith("first", "workspace_storage_provider", { - executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "plugin" }, - environmentId: "computer", expectedStorageProviderPluginId: "plugin", - }); - expect(container.querySelector('[data-project="first"] select[id^="project-environment-"]')!.value).toBe("computer"); - expect(container.querySelector('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local"); -}); - -it("keeps the saved mapping and allows an explicit clear while the environment list loads", () => { - vi.mocked(projectsApi.environmentOptions).mockReturnValueOnce(new Promise(() => {})); - renderProjects(false); - const first = container.querySelector('[data-project="first"] select[id^="project-environment-"]')!; - expect(first.value).toBe("local"); - expect(first.selectedOptions[0].textContent).toBe("Saved environment"); - expect(first.disabled).toBe(false); - flushSync(() => { first.value = ""; first.dispatchEvent(new Event("change", { bubbles: true })); }); - expect(update).toHaveBeenCalledExactlyOnceWith("first", "execution_workspace_environment", { environmentId: null }); - expect(container.querySelector('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local"); -}); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index f3e547aab45..07cd98072c0 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -24,7 +24,6 @@ import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; import { EnvironmentVariablesEditor } from "./environment-variables-editor"; import { QueryErrorState, useQueryView } from "./QueryView"; -import { NativeSelect } from "./ui/select"; import { Badge } from "@/components/ui/badge"; import { ProjectAccessMembers } from "./ProjectAccessMembers"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -48,11 +47,9 @@ export type ProjectConfigFieldKey = | "visibility" | "goals" | "env" - | "workspace_storage_provider" | "execution_workspace_enabled" | "execution_workspace_default_mode" | "execution_workspace_shared_concurrency" - | "execution_workspace_environment" | "execution_workspace_base_ref" | "execution_workspace_branch_template" | "execution_workspace_worktree_parent_dir" @@ -240,7 +237,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); - const environmentsEnabled = experimentalSettings?.enableEnvironments === true; const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), @@ -263,10 +259,15 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); }, }); + const providerConfiguration = project.workspaceProvider; + const projectStoragePluginId = project.executionWorkspacePolicy?.storageProviderPluginId ?? null; + const showStorageAvailability = Boolean(projectStoragePluginId || providerConfiguration?.providers.length); + const storagePluginUnavailable = Boolean(projectStoragePluginId + && !providerConfiguration?.providers.some(provider => provider.id === projectStoragePluginId)); const projectEnvironments = useQuery({ queryKey: queryKeys.projects.environments(project.companyId), queryFn: () => projectsApi.environmentOptions(project.companyId), - enabled: Boolean(project.companyId), + enabled: Boolean(project.companyId) && showStorageAvailability && !storagePluginUnavailable, }); const projectEnvironmentView = useQueryView(projectEnvironments); @@ -275,7 +276,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda const primaryCodebaseWorkspace = project.primaryWorkspace ?? null; const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId); const storedExecutionWorkspacePolicy = project.executionWorkspacePolicy ?? null; - const providerConfiguration = project.workspaceProvider; const executionWorkspacePolicy = providerConfiguration?.effectivePolicy ?? storedExecutionWorkspacePolicy; const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; @@ -298,9 +298,9 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda // policy reads as "not managed", which would show the local folder the policy // exists to hide. const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly; - const projectEnvironmentChoices = projectEnvironmentView.data?.groups - .filter(group => group.storageProviderPluginId === (storedExecutionWorkspacePolicy?.storageProviderPluginId ?? null)) - .flatMap(group => group.environments) ?? []; + const storageEnvironmentNames = projectEnvironmentView.data?.groups + .filter(group => group.storageProviderPluginId === projectStoragePluginId) + .flatMap(group => group.environments.map(environment => environment.name)) ?? []; const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); @@ -726,70 +726,24 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda )} } - {environmentsEnabled && project.companyId ? ( -
- - { - const environmentId = event.target.value || null; - const group = projectEnvironmentView.data?.groups.find(value => value.environments.some(environment => environment.id === environmentId)); - commitField("execution_workspace_environment", { environmentId, - ...(group ? { expectedStorageProviderPluginId: group.storageProviderPluginId } : {}) }); - }}> - - {project.environmentId && !projectEnvironmentChoices.some(environment => environment.id === project.environmentId) - ? : null} - {projectEnvironmentChoices.map(environment => )} - -

Applies only to this project. Agent and company defaults stay unchanged.

- {projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting" - ?

Loading environments…

: null} - {projectEnvironmentView.kind === "error" ? : null} -
- ) : null} - - {providerConfiguration ? ( -
- - + {showStorageAvailability ? ( +
+
Available in
+ {storagePluginUnavailable ? ( +

Storage plugin unavailable

+ ) : projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting" ? ( +

Loading environments…

+ ) : projectEnvironmentView.kind === "error" ? ( + + ) : ( +

{storageEnvironmentNames.length ? storageEnvironmentNames.join(", ") : "No available environments"}

+ )}

- {storedExecutionWorkspacePolicy?.storageProviderPluginId - ? `This project stays on ${providerConfiguration.displayName}.` - : "A project moved to a storage plugin stays on that plugin."} + {providerConfiguration?.displayName ?? "Core / local"} workspace storage was chosen when this project was created and does not change.

- {providerConfiguration.error ?

{providerConfiguration.error}

: null} + {providerConfiguration?.error && !storagePluginUnavailable + ?

{providerConfiguration.error}

: null}
) : null}