diff --git a/doc/project-environment-compatibility.md b/doc/project-environment-compatibility.md new file mode 100644 index 00000000000..b8828321b90 --- /dev/null +++ b/doc/project-environment-compatibility.md @@ -0,0 +1,70 @@ +# Project environments and repository storage + +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 selects storage; it +is not an allowlist or an override of an agent's run environment. + +## Compatibility rule + +Use the existing workspace execution contract, not environment names, driver +names alone, images, operating systems, endpoints, or complete config equality: + +- Local, SSH, and sandbox environments use core repository staging. They can use + the same repository project even across driver types and sandbox providers. +- Plugin-managed storage belongs to an installed plugin ID. It can be consumed + only by that plugin's registered `environment_driver` with `supportsTasks: true`. + Its manifest must declare `environment.drivers.register`, `workspaces.manage`, + a workspace provider, and the project resource lifecycle. +- Two task environments from that installed plugin share its project storage + contract, even when their other settings differ. Storage operations are scoped + by company, project, and plugin; they do not take an environment configuration. + Separate installed storage plugins remain incompatible even when both use the + `plugin` driver. Core cannot transfer their managed bindings between them. +- Unknown plugin drivers and the probe-only fake sandbox do not establish a + compatible project storage contract. They are excluded from creation choices. + +These are project repository compatibility rules. They do not assert that +arbitrary local paths exist remotely, that a retained task workspace can switch +hosts, or that an adapter, credential, image, or running worker is ready. Existing +run-time validation still checks those conditions. Cross-plugin storage sharing +would require a new execution/storage contract; matching config fields cannot +establish it. + +## Defaults and validation + +For an agent API request, use that agent's default environment. For a person, +use their primary agent's default environment when present. Fall back to the +instance default, then local execution. There is no separate user or company +`defaultEnvironmentId` setting today. Managed-only and forced Kubernetes policies +retain their execution restrictions and default redirection. + +`GET /api/companies/:companyId/project-environments` returns environment IDs and +names grouped by storage owner, plus the caller's effective default. It excludes +archived environments, disabled/uninstalled storage plugins, and managed +environments bound only to another company. It does not contact workers or return +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. The project +is then available to the compatible environments in that group; no single run +environment is pinned. 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 +policy; instance task-isolation defaults remain inherited. An explicit policy +and environment must agree when both are supplied. Without either, +storage follows the caller's effective environment. A plugin's +`defaultForProjects` claim alone no longer attaches a project to that plugin. + +The hidden repository-free “My private tasks” project is independent of +environment/storage availability, so creating a private note never requires +repairing an environment. + +An unavailable configured default produces an error and permits an explicit +available choice. It does not silently select another storage owner. Existing +projects keep their stored owner. This change does not migrate managed storage +or detach projects that contain existing work. A managed project run in an +incompatible environment fails with `project_environment_incompatible` before a +workspace provider operation, with a message naming the storage and environment. diff --git a/maps/feature-map/projects.md b/maps/feature-map/projects.md index b793d57d678..42bb57238d1 100644 --- a/maps/feature-map/projects.md +++ b/maps/feature-map/projects.md @@ -29,8 +29,8 @@ Open `/projects`, create a project, and enter its overview or Issues view. ### `repository-settings` Open `/projects/:projectId/configuration` and its repository/workspace settings. -Workspace storage shows the inherited installed default and allows an explicit -provider or Core/local choice. Conflicting defaults remain visible for repair. +Workspace storage shows the saved provider or Core/local choice. Plugin-backed +projects retain their storage owner. ### `project-access` @@ -48,7 +48,7 @@ Manual: Create a project, add a task from its view, and reload. Verify the task ### `repository-settings` -Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover inherited choices, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage. +Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover saved storage, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage. Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7ce86fb2ce0..a4ec3e9cca1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -944,6 +944,7 @@ export type { ProjectBudgetSummary, ProjectRepository, ProjectRepositoryOptions, + ProjectEnvironmentOptions, ProjectGitAuthorization, ProjectCodebase, ProjectCodebaseOrigin, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0f9b69e2285..01845e2a041 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -366,6 +366,7 @@ export type { Project, ProjectRepository, ProjectRepositoryOptions, + ProjectEnvironmentOptions, ProjectGitAuthorization, ProjectAccessMember, ProjectAccessSubjectType, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 43a5232bdd1..ae2e718acd6 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -107,6 +107,15 @@ export interface ProjectDiscoveryPage { nextCursor: string | null; } +export interface ProjectEnvironmentOptions { + groups: Array<{ + storageProviderPluginId: string | null; + environments: Array<{ id: string; name: string }>; + }>; + defaultEnvironmentId: string | null; + error: string | null; +} + export interface ProjectWorkspaceProviderConfiguration { source: "project" | "core"; pluginId: string | null; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 61839f7a1b5..0257acc2940 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -126,6 +126,8 @@ const projectFields = { export const createProjectSchema = z.object({ idempotencyKey: z.string().trim().min(1).max(255).optional(), ...projectFields, + environmentId: z.string().guid().optional(), + expectedStorageProviderPluginId: z.string().guid().nullable().optional(), workspace: createProjectWorkspaceSchema.optional(), repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(), repositoryUrls: z.array(z.string().url().max(2000)).max(100).optional(), diff --git a/server/src/__tests__/heartbeat-session-preparation.test.ts b/server/src/__tests__/heartbeat-session-preparation.test.ts index c9fa1b5bc09..f918f482b12 100644 --- a/server/src/__tests__/heartbeat-session-preparation.test.ts +++ b/server/src/__tests__/heartbeat-session-preparation.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db"; +import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, environments, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db"; import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js"; import { heartbeatService } from "../services/heartbeat.js"; import { instanceSettingsService } from "../services/instance-settings.js"; @@ -120,9 +120,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => const f = await seed(); const storage = await createPluginWorkspaceFixture(db, f.companyId); const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId)); - await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: { - ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" }, - } } }).where(eq(plugins.id, storage.pluginId)); + const [environment] = await db.insert(environments).values({ name: `Storage tasks ${storage.pluginId}`, driver: "plugin", status: "active", + config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } }).returning(); + await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, + capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"], + environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }], + workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } }, + } }).where(eq(plugins.id, storage.pluginId)); + await db.update(agents).set({ defaultEnvironmentId: environment.id }).where(eq(agents.id, f.agentId)); await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId)); await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId, executionWorkspaceSettings: settings, @@ -132,6 +137,9 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => const createOrchestrator = environmentOrchestrator.environmentRunOrchestrator; const realization = vi.spyOn(environmentOrchestrator, "environmentRunOrchestrator").mockImplementation((...args) => ({ ...createOrchestrator(...args), + // These tests dispatch a synthetic adapter; substitute only the provider + // transport while preserving the configured storage/task-driver identity. + acquireForRun: async input => createOrchestrator(...args).acquireForRun({ ...input, selectedEnvironmentId: input.localEnvironmentId }), realizeForRun: async input => { input.executionWorkspace.cwd = cwd; return { lease: input.lease, persistedExecutionWorkspace: input.persistedExecutionWorkspace, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 53d74afb9cb..7a419c064dd 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -1300,6 +1300,17 @@ describe("openapi routes", () => { expect(spec.paths[base + "/event-test"].post.requestBody.content["application/json"].schema.properties.bindingId).toMatchObject({ type: "string", format: "uuid" }); }); + it("documents project environment compatibility and creation selection", () => { + const { spec } = loadSpecRoutes(); + const operation = spec.paths["/api/companies/{companyId}/project-environments"].get; + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" }); + expect(operation.responses["403"]).toBeDefined(); + expect(operation.responses["200"].content["application/json"].schema.properties).toHaveProperty("defaultEnvironmentId"); + const creation = spec.paths["/api/companies/{companyId}/projects"].post; + expect(creation.requestBody.content["application/json"].schema.properties.environmentId).toMatchObject({ type: "string", format: "uuid" }); + expect(creation.requestBody.content["application/json"].schema.properties.expectedStorageProviderPluginId).toMatchObject({ type: "string", nullable: true }); + }); + it("documents board-only repository discovery and selection", () => { const { spec } = loadSpecRoutes(); const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get; diff --git a/server/src/__tests__/project-environments.test.ts b/server/src/__tests__/project-environments.test.ts new file mode 100644 index 00000000000..815fa3c1cdd --- /dev/null +++ b/server/src/__tests__/project-environments.test.ts @@ -0,0 +1,220 @@ +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, userCompanyPreferences } from "@paperclipai/db"; +import { projectEnvironmentOptions, withDefaultProjectStorage } from "../services/project-environments.js"; +import { assertProjectStorageEnvironment, projectStorageForEnvironment } from "../services/project-workspace-provider.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"; +import { projectService } from "../services/projects.js"; +import { preparePluginWorkspace } from "../services/plugin-workspaces.js"; +import { projectRoutes } from "../routes/projects.js"; +import { errorHandler } from "../middleware/index.js"; +import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("project environment compatibility", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("project-environments"); + db = createDb(database.connectionString); + }, 120_000); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + beforeEach(async () => { await db.execute(sql`truncate companies, plugins, environments, instance_settings cascade`); }); + + async function fixture() { + const f = await createPluginWorkspaceFixture(db); + const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId)); + plugin.manifestJson = { ...plugin.manifestJson, + capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"], + environmentDrivers: [{ driverKey: "tasks", supportsTasks: true, displayName: "Computers", configSchema: { type: "object", properties: {} } }], + workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } }, + }; + await db.update(plugins).set({ manifestJson: plugin.manifestJson }).where(eq(plugins.id, plugin.id)); + const [computer, local, sandbox, ssh] = await db.insert(environments).values([ + { name: "Computers", driver: "plugin", config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } }, + { name: "Local", driver: "local" }, + { name: "Sandbox", driver: "sandbox", config: { provider: "daytona" } }, + { name: "Build host", driver: "ssh", config: { host: "build.example.com" } }, + ]).returning(); + await db.insert(instanceSettings).values({ defaultEnvironmentId: sandbox.id }); + return { ...f, plugin, computer, local, sandbox, ssh }; + } + + it("groups by repository storage, across driver types and provider configurations", async () => { + const f = await fixture(); + const [otherComputer] = await db.insert(environments).values({ name: "Other computers", driver: "plugin", + config: { ...f.computer.config, driverConfig: { profile: "other" } } }).returning(); + const [otherSandbox] = await db.insert(environments).values({ name: "Other sandbox", driver: "sandbox", config: { provider: "modal" } }).returning(); + const options = await projectEnvironmentOptions(db, f.companyId); + expect(options.error).toBeNull(); + expect(options.defaultEnvironmentId).toBe(f.sandbox.id); + expect(options.groups).toHaveLength(2); + expect(options.groups.find(group => group.storageProviderPluginId === null)?.environments.map(value => value.id).sort()) + .toEqual([f.local.id, f.sandbox.id, f.ssh.id, otherSandbox.id].sort()); + expect(options.groups.find(group => group.storageProviderPluginId === f.pluginId)?.environments.map(value => value.id).sort()) + .toEqual([f.computer.id, otherComputer.id].sort()); + expect(JSON.stringify(options)).not.toContain("driverConfig"); + expect(JSON.stringify(options)).not.toContain("build.example.com"); + }); + + it("does not equate two plugin drivers with different storage owners", async () => { + const f = await fixture(); + const other = await createPluginWorkspaceFixture(db, f.companyId); + await db.update(plugins).set({ manifestJson: { ...f.plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId)); + await db.insert(environments).values({ name: "Separate computers", driver: "plugin", config: { pluginKey: `test.${other.pluginId}`, driverKey: "tasks" } }); + expect((await projectEnvironmentOptions(db, f.companyId)).groups).toHaveLength(3); + expect(projectStorageForEnvironment({ driver: "plugin", config: { pluginKey: f.plugin.pluginKey, driverKey: "unknown" } }, [f.plugin])).toBeNull(); + expect(projectStorageForEnvironment({ driver: "sandbox", config: { provider: "fake" } }, [f.plugin])).toBeNull(); + }); + + it("uses the creating agent, the user's primary agent, then the instance default", async () => { + const f = await fixture(); + await db.update(agents).set({ defaultEnvironmentId: f.computer.id }).where(eq(agents.id, f.input.agentId)); + await db.insert(userCompanyPreferences).values({ companyId: f.companyId, userId: "person", primaryAgentId: f.input.agentId }); + 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 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 }); + expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.local.id); + }); + + it("pins the selected storage, preserves explicit API choices, and rejects conflicting choices", async () => { + const f = await fixture(); + const svc = projectService(db); + const sandboxProject = await svc.createWithRepositories(f.companyId, { name: "Sandbox project" }, [ + { id: "42", fullName: "example/repo", url: "https://github.com/example/repo", connections: [] }, + ]); + expect(sandboxProject.workspaceProvider?.pluginId).toBeNull(); + 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 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("preserves inherited task isolation when checking the observed core storage owner", async () => { + const f = await fixture(); + const { experimental: settings } = await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: true }); + const project = await projectService(db).createWithRepositories(f.companyId, { name: "Inherited isolation" }, [ + { id: "42", fullName: "example/repo", url: "https://github.com/example/repo", connections: [] }, + ], { environmentId: f.sandbox.id, expectedStorageProviderPluginId: null }); + expect(project.executionWorkspacePolicy).toBeNull(); + const projectPolicy = applyDefaultIsolatedExecutionWorkspacePolicy({ + projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(project.executionWorkspacePolicy), settings.enableIsolatedWorkspaces), + defaultIsolatedWorkspacesEnabled: settings.enableIsolatedWorkspacesByDefault, + hasProjectWorkspace: project.workspaces.length > 0, + }); + const mode = resolveExecutionWorkspaceMode({ projectPolicy, issueSettings: null, legacyUseProjectWorkspace: null }); + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy, issueSettings: null, mode, legacyUseProjectWorkspace: null }); + expect(mode).toBe("isolated_workspace"); + expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree"); + }); + + it("fails before a provider operation when managed storage cannot serve the selected environment", async () => { + const f = await fixture(); + expect(() => assertProjectStorageEnvironment(f.plugin, { ...f.sandbox, driver: "sandbox" })).toThrow('does not support the environment "Sandbox"'); + await expect(preparePluginWorkspace(db, f.workers, { ...f.input, environment: { ...f.sandbox, driver: "sandbox" } })) + .rejects.toMatchObject({ status: 409, details: { code: "project_environment_incompatible", retryable: false } }); + expect(f.calls).toHaveLength(0); + expect(() => assertProjectStorageEnvironment(f.plugin, { ...f.computer, driver: "plugin" })).not.toThrow(); + }); + + it("excludes archived, company-inaccessible, and disabled plugin environments", async () => { + const f = await fixture(); + 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 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(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 }); + } + }); + + it("keeps a cold worker's storage identity, but refuses an archived default", async () => { + 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 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(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 => { + const f = await fixture(); + const userId = randomUUID(); + const now = new Date(); + await db.insert(authUsers).values({ id: userId, name: "Person", email: `${userId}@example.com`, createdAt: now, updatedAt: now }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, + status: "active", membershipRole: "operator" }); + await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id)); + if (source === "instance") await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id }); + else { + await db.update(agents).set({ defaultEnvironmentId: f.computer.id }).where(eq(agents.id, f.input.agentId)); + await db.insert(userCompanyPreferences).values({ companyId: f.companyId, userId, primaryAgentId: f.input.agentId }); + } + const task = await issueService(db).create(f.companyId, { title: "Private note", visibility: "private", createdByUserId: userId }); + expect(task.visibility).toBe("private"); + const project = await projectService(db).getById(task.projectId!); + expect(project?.name).toBe("My private tasks"); + expect(project?.executionWorkspacePolicy).toBeNull(); + expect(project?.workspaces).toEqual([]); + }); + + it("honors managed-only and forced Kubernetes execution policy", async () => { + const f = await fixture(); + await db.update(environments).set({ metadata: { managedByPaperclip: true, managedKubernetesSandbox: true } }).where(eq(environments.id, f.sandbox.id)); + await db.update(instanceSettings).set({ defaultEnvironmentId: f.local.id, experimental: { enableManagedSandboxOnly: true } }); + const managed = await projectEnvironmentOptions(db, f.companyId); + expect(managed.defaultEnvironmentId).toBe(f.sandbox.id); + expect(managed.groups.flatMap(group => group.environments).map(value => value.id)).not.toContain(f.local.id); + 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(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 () => { + const f = await fixture(); + function app(actor: Express.Request["actor"]) { + const result = express(); result.use(express.json()); + result.use((req, _res, next) => { req.actor = actor; next(); }); + result.use("/api", projectRoutes(db)); result.use(errorHandler); return result; + } + 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`); + expect(options.status).toBe(200); + expect(options.body.groups).toHaveLength(2); + 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 }); + 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); + const agent = app({ type: "agent", agentId: f.input.agentId, companyId: f.companyId }); + expect((await request(agent).get(`${url}/project-environments`)).status).toBe(200); + await db.update(environments).set({ driver: "plugin", config: f.computer.config }).where(eq(environments.id, f.ssh.id)); + const changed = await request(board).post(`${url}/projects`).send({ name: "Changed storage", environmentId: f.ssh.id, + expectedStorageProviderPluginId: null }); + expect(changed.status).toBe(409); + expect(changed.body.details.code).toBe("project_environment_incompatible"); + const invalid = await request(board).post(`${url}/projects`).send({ name: "Invalid", environmentId: randomUUID() }); + expect(invalid.status).toBe(409); + }); +}); diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 7f929c23436..4877bdff41b 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -208,6 +208,7 @@ describe("project env routes", () => { expect(mockProjectService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ env: normalizedEnv }), + expect.objectContaining({ userId: "board-user" }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), diff --git a/server/src/__tests__/project-workspace-provider.integration.test.ts b/server/src/__tests__/project-workspace-provider.integration.test.ts index deab86e3248..fe9279b02f0 100644 --- a/server/src/__tests__/project-workspace-provider.integration.test.ts +++ b/server/src/__tests__/project-workspace-provider.integration.test.ts @@ -36,13 +36,13 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = return f; } - it("attaches a new project to the installed default and leaves existing projects where they are", async () => { + 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).toMatchObject({ enabled: true, storageProviderPluginId: f.pluginId }); - expect(created.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId }); + 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 }); @@ -73,10 +73,10 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = .toMatchObject({ source: "project", pluginId: other.pluginId }); }); - it("keeps a personal project on the installed default", async () => { + 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).toMatchObject({ storageProviderPluginId: f.pluginId }); + expect(project.executionWorkspacePolicy).toBeNull(); }); it.each(["create", "import"] as const)("%s leaves an unspecified task mode inherited from the effective provider", async method => { @@ -153,7 +153,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = 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).toMatchObject({ storageProviderPluginId: f.pluginId }); + 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"); }); @@ -175,13 +175,13 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () = expect(await db.select().from(executionWorkspaces)).toHaveLength(0); }); - it("asks for a choice when creating a project with conflicting defaults, and leaves existing projects working", async () => { + 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); - await expect(svc.create(f.companyId, { name: "Ambiguous" })).rejects.toThrow("Multiple workspace storage plugins"); + 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 }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 2e4689bfe53..773d9acb745 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -5126,6 +5126,26 @@ registry.registerPath({ // ─── Projects ──────────────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/project-environments", + tags: ["projects"], + summary: "List compatible project environments and the caller's default", + description: "Groups configured environments by repository storage owner. Returns names and IDs without provider configuration. Creation revalidates environmentId and stores its storage owner without overriding agent execution environments.", + request: { params: z.object({ companyId: z.string() }) }, + responses: { + 200: r.ok(z.object({ + groups: z.array(z.object({ + storageProviderPluginId: z.string().uuid().nullable(), + environments: z.array(z.object({ id: z.string().uuid(), name: z.string() })), + })), + defaultEnvironmentId: z.string().uuid().nullable(), + error: z.string().nullable(), + })), + 401: r.unauthorized, 403: r.forbidden, + }, +}); + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/project-repositories", diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 64e9a831d59..47a2a179946 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,3 +1,4 @@ +import { projectEnvironmentOptions } from "../services/project-environments.js"; import { projectGitAuthorizationService } from "../services/project-git-authorization.js"; import { skillAccessRequestContext } from "../services/skill-access.js"; import { createHash } from "node:crypto"; @@ -375,15 +376,26 @@ export function projectRoutes(db: Db) { res.json(summary); }); + router.get("/companies/:companyId/project-environments", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await projectEnvironmentOptions(db, companyId, { + userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId, + agentId: req.actor.type === "agent" ? req.actor.agentId : null, + })); + }); + router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); type CreateProjectPayload = Parameters[1] & { workspace?: Parameters[1]; repositoryIds?: string[]; + environmentId?: string; + expectedStorageProviderPluginId?: string | null; }; - const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] }; + const { workspace, repositoryIds, repositoryUrls, idempotencyKey, environmentId, expectedStorageProviderPluginId, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] }; const receiptOnly = req.get("X-Paperclip-Receipt-Only") === "true"; if (receiptOnly && !idempotencyKey) throw unprocessable("Project receipt recovery requires an idempotency key"); projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null; @@ -412,7 +424,7 @@ export function projectRoutes(db: Db) { const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl); const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null; const actor = getActorInfo(req); - const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex"); + const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls, environmentId, expectedStorageProviderPluginId })).digest("hex"); const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null; const result = await db.transaction(async (tx) => { if (receiptKey) { @@ -457,7 +469,13 @@ export function projectRoutes(db: Db) { if (receiptOnly) return null; if (runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true); const service = projectService(tx as unknown as Db); - const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData); + const environment = { + environmentId, + expectedStorageProviderPluginId, + userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId, + agentId: req.actor.type === "agent" ? req.actor.agentId : null, + }; + const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories, environment) : await service.create(companyId, projectData, environment); if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db); const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase())); const registeredUrls: typeof urlRepositories = []; diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 487e4a09a5b..9f80accad3f 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -265,7 +265,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace driver: "sandbox" as const, config: { provider: "daytona" } }; await db.update(agents).set({ adapterType: "paperclip_runner" }).where(eq(agents.id, input.agent.id)); input.agent.adapterType = "paperclip_runner"; - input.environment.selectedEnvironmentForConfig = environment; + input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id); input.environment.selectedEnvironmentId = environment.id; expect(input.environment.resolvedInstanceSettings.experimental.remoteWorkspaceStorageEnabled).toBe(true); input.policy.requestedExecutionWorkspaceMode = "agent_default"; @@ -964,7 +964,13 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace const pluginKey = `test.managed-storage-${fromDefault}-${defaultMode}-${fresh}`; const [plugin] = await db.insert(plugins).values({ pluginKey, packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"], manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"], - workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe", "environment.drivers.register"], + environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }], + resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning(); + const [environment] = await db.insert(environments).values({ name: `Storage tasks ${pluginKey}`, driver: "plugin", status: "active", + config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning(); + input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id); + input.environment.selectedEnvironmentId = environment.id; const savedPolicy = fromDefault ? { enabled: true, storageProviderPluginId: plugin.id } : { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const, workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } }; await db.update(projects).set({ executionWorkspacePolicy: savedPolicy }).where(eq(projects.id, issue.projectId!)); @@ -989,7 +995,8 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace const acquire = input.services.envOrchestrator.acquireForRun; vi.spyOn(input.services.envOrchestrator, "acquireForRun").mockImplementation(async args => { sequence.push("acquire"); expect(args.persistedExecutionWorkspace).toMatchObject({ providerType: "plugin", cwd: null }); - return acquire(args); + // Acquisition is a transport stub: this boundary test stops at handoff. + return acquire({ ...args, selectedEnvironmentId: args.localEnvironmentId }); }); vi.spyOn(input.services.envOrchestrator, "realizeForRun").mockImplementation(async args => { expect(args.executionWorkspace.providerBinding?.workspaceRef).toBe("provider:checkout"); @@ -1090,7 +1097,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace environmentDrivers: [{ driverKey: "tasks", displayName: "Task VM", configSchema: { type: "object" }, supportsTasks: true, freshWorkspaceVersion: 1 }] } }); const environment = await environmentService(db).create({ name: `Task computer ${issue.id}`, driver: "plugin", status: "active", envVars: {}, config: { pluginKey, driverKey: "tasks" } }); input.agent = { ...input.agent, adapterType: "paperclip_runner", defaultEnvironmentId: environment.id }; - input.environment.selectedEnvironmentForConfig = environment; + input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id); input.policy.requestedExecutionWorkspaceMode = input.policy.effectiveExecutionWorkspaceMode = "isolated_workspace"; input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] }; const stopped = new Error("Provider directory prepared"); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 25ac839f4cb..63e539e5024 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -554,6 +554,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace existingWorkspaceId: providerFresh ? providerFresh.previousWorkspaceId : reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, ...(providerFresh ? { fresh: providerFresh, expectedBindingRevision: previousProviderBindingRevision } : {}), selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null, + environment: selectedEnvironmentForConfig, mode: requestedExecutionWorkspaceMode, strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig), branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create", diff --git a/server/src/services/plugin-workspaces.ts b/server/src/services/plugin-workspaces.ts index 1a9f8ff7e7e..e7abdb4698d 100644 --- a/server/src/services/plugin-workspaces.ts +++ b/server/src/services/plugin-workspaces.ts @@ -1,5 +1,5 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; -import { effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js"; +import { assertProjectStorageEnvironment, effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js"; import { releaseProviderRecoveryTransfers, stageProviderFreshWorkspace } from "./provider-fresh-workspace.js"; import { retireAbandonedPluginWorkspaceReservation } from "./plugin-workspace-reservations.js"; import { randomUUID, createHash } from "node:crypto"; @@ -198,6 +198,7 @@ export async function preparePluginWorkspace( mode: string; strategy: string; selectedProviderPluginId?: string | null; + environment?: Pick | null; branchMode?: "create" | "existing"; branchName?: string | null; baseRef?: string | null; @@ -276,6 +277,7 @@ export async function preparePluginWorkspace( "An existing local workspace cannot change storage providers", ); const selectedProvider = await provider(db, workers, input.companyId, pluginId, true); + if (input.environment) assertProjectStorageEnvironment(selectedProvider, input.environment); const declaration = selectedProvider.manifestJson.workspaceProvider!; if (input.fresh && declaration.freshWorkspaceVersion !== 1) throw conflict("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", { reason: "fresh_workspace_storage_unsupported" }); diff --git a/server/src/services/project-environments.ts b/server/src/services/project-environments.ts new file mode 100644 index 00000000000..945f5052823 --- /dev/null +++ b/server/src/services/project-environments.ts @@ -0,0 +1,91 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import { agents, builtInManagedResources, userCompanyPreferences, type Db } from "@paperclipai/db"; +import type { ProjectEnvironmentOptions } from "@paperclipai/shared"; +import { conflict } from "../errors.js"; +import { environmentService } from "./environments.js"; +import { instanceSettingsService } from "./instance-settings.js"; +import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { listProjectWorkspaceProviders, projectStorageForEnvironment } from "./project-workspace-provider.js"; + +export interface ProjectCreationEnvironment { + userId?: string | null; + agentId?: string | null; + environmentId?: string; + expectedStorageProviderPluginId?: string | null; +} + +export async function projectEnvironmentOptions(db: Db, companyId: string, actor: ProjectCreationEnvironment = {}): Promise { + const svc = environmentService(db); + const [settings, environments, providers, bindings] = await Promise.all([ + instanceSettingsService(db).get(), + svc.list({ status: "active" }), + listProjectWorkspaceProviders(db, companyId), + db.select({ environmentId: builtInManagedResources.resourceId, companyId: builtInManagedResources.companyId }) + .from(builtInManagedResources).where(eq(builtInManagedResources.resourceKind, "environment")), + ]); + let preferredEnvironmentId = settings.defaultEnvironmentId; + if (actor.agentId || actor.userId) { + const candidates = db.select({ environmentId: agents.defaultEnvironmentId }).from(agents); + const [agent] = actor.agentId + ? await candidates.where(and(eq(agents.companyId, companyId), eq(agents.id, actor.agentId))) + : await candidates.innerJoin(userCompanyPreferences, and(eq(userCompanyPreferences.primaryAgentId, agents.id), + eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.userId, actor.userId!))) + .where(and(eq(agents.companyId, companyId), notInArray(agents.status, ["terminated", "pending_approval"]))); + preferredEnvironmentId = agent?.environmentId ?? preferredEnvironmentId; + } + let preferred = environments.find(environment => environment.id === preferredEnvironmentId) + ?? (!preferredEnvironmentId ? environments.find(environment => environment.driver === "local") : undefined); + const forcedKubernetes = settings.general.executionMode === "kubernetes"; + const managedOnly = settings.experimental.enableManagedSandboxOnly === true; + if (forcedKubernetes) { + preferred = environments.find(environment => environment.driver === "sandbox" && environment.metadata?.managedKubernetesSandbox === true); + } else if (managedOnly && (!preferred || preferred.driver === "local")) { + preferred = environments.find(environment => environment.driver === "sandbox" && environment.metadata?.managedByPaperclip === true); + } + + const groups: ProjectEnvironmentOptions["groups"] = []; + for (const environment of environments) { + const owners = bindings.filter(binding => binding.environmentId === environment.id); + if (owners.length && !owners.some(binding => binding.companyId === companyId)) continue; + if (forcedKubernetes && environment.id !== preferred?.id) continue; + if (managedOnly && environment.driver === "local") continue; + const storage = projectStorageForEnvironment(environment, providers); + if (!storage) continue; + let group = groups.find(value => value.storageProviderPluginId === storage.pluginId); + if (!group) { + group = { storageProviderPluginId: storage.pluginId, environments: [] }; + groups.push(group); + } + group.environments.push({ id: environment.id, name: environment.name }); + } + for (const group of groups) group.environments.sort((a, b) => a.name.localeCompare(b.name)); + groups.sort((a, b) => a.environments[0].name.localeCompare(b.environments[0].name)); + const defaultAvailable = preferred && groups.some(group => group.environments.some(environment => environment.id === preferred.id)); + return { + groups, + defaultEnvironmentId: defaultAvailable ? preferred?.id ?? null : null, + error: !defaultAvailable && (preferredEnvironmentId || forcedKubernetes || managedOnly || environments.length > 0) + ? "The default environment cannot use project workspace storage. Select an available environment or repair the default environment configuration." + : null, + }; +} + +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 rawPolicy; + const options = await projectEnvironmentOptions(db, companyId, actor); + const environmentId = actor.environmentId ?? options.defaultEnvironmentId; + const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId)); + if (actor.environmentId && !group) throw conflict("The selected environment is unavailable for this project. Select an available environment.", { code: "project_environment_unavailable" }); + if (!actor.environmentId && options.error) throw conflict(options.error, { code: "project_environment_unavailable" }); + const pluginId = group?.storageProviderPluginId ?? null; + if (actor.expectedStorageProviderPluginId !== undefined && actor.expectedStorageProviderPluginId !== pluginId) { + throw conflict("The environment's project storage changed. Review the available environments and retry.", { code: "project_environment_incompatible" }); + } + if (policy?.storageProviderPluginId !== undefined && policy.storageProviderPluginId !== pluginId) { + throw conflict("The selected environment cannot use the requested project workspace storage.", { code: "project_environment_incompatible" }); + } + return pluginId || policy?.storageProviderPluginId !== undefined + ? { enabled: true, ...policy, storageProviderPluginId: pluginId } + : rawPolicy; +} diff --git a/server/src/services/project-workspace-provider.ts b/server/src/services/project-workspace-provider.ts index bd216b5aebe..946244a7545 100644 --- a/server/src/services/project-workspace-provider.ts +++ b/server/src/services/project-workspace-provider.ts @@ -1,6 +1,6 @@ import { and, eq, notInArray } from "drizzle-orm"; import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db"; -import type { PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared"; +import type { Environment, PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { conflict } from "../errors.js"; @@ -68,19 +68,33 @@ export function sourcelessProjectWorkspacePolicy(rawPolicy: unknown): ProjectExe return Object.keys(settings).some(key => key !== "enabled") ? settings : null; } -export async function withDefaultProjectStorage(db: ProviderDb, companyId: string, rawPolicy: unknown) { - if (parseProjectExecutionWorkspacePolicy(rawPolicy)?.storageProviderPluginId !== undefined) return rawPolicy; - const defaults = (await listProjectWorkspaceProviders(db, companyId)) - .filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects); - if (defaults.length > 1) { - throw conflict(`Multiple workspace storage plugins claim the project default: ${defaults.map(plugin => plugin.manifestJson.displayName).sort().join(", ")}. Select a storage provider in project configuration or disable a conflicting provider.`); - } - if (!defaults[0]) return rawPolicy; - return { enabled: true, ...(rawPolicy as Record | null), storageProviderPluginId: defaults[0].id }; -} - export async function effectiveProjectWorkspacePolicy(db: ProviderDb, companyId: string, rawPolicy: unknown, hasGitSource: boolean) { const configuration = resolveProjectWorkspaceProvider(rawPolicy, await listProjectWorkspaceProviders(db, companyId), hasGitSource); if (configuration.error) throw conflict(configuration.error); return configuration.effectivePolicy; } + +export function projectStorageForEnvironment( + environment: Pick, + providers: Array>, +): { pluginId: string | null } | null { + if (["local", "ssh", "sandbox"].includes(environment.driver)) { + return environment.driver === "sandbox" && environment.config.provider === "fake" ? null : { pluginId: null }; + } + if (environment.driver !== "plugin") return null; + const plugin = providers.find(value => value.pluginKey === environment.config.pluginKey); + const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === environment.config.driverKey + && value.kind !== "sandbox_provider" && value.supportsTasks === true); + return plugin && driver && plugin.manifestJson.capabilities.includes("environment.drivers.register") + ? { pluginId: plugin.id } : null; +} + +export function assertProjectStorageEnvironment( + provider: Pick, + environment: Pick, +) { + if (projectStorageForEnvironment(environment, [provider])?.pluginId !== provider.id) { + throw conflict(`This project's workspace storage (${provider.manifestJson.displayName}) does not support the environment "${environment.name}". Use an agent in a compatible environment, or create a project for this environment.`, + { code: "project_environment_incompatible", retryable: false }); + } +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 92e877d9bc1..cd2275b690d 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,6 +1,7 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js"; -import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, withDefaultProjectStorage } from "./project-workspace-provider.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"; import { enqueueProjectSkillSource, detachProjectSkillSource } from "./project-skill-source-bindings.js"; @@ -115,7 +116,6 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin visibility: "private", personalOwnerUserId: userId, privacyOwnerUserId: userId, - executionWorkspacePolicy: await withDefaultProjectStorage(dbOrTx, companyId, null) as Record | null, }) .onConflictDoNothing() .returning() @@ -654,6 +654,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool const createProject = async ( companyId: string, data: Omit & { goalIds?: string[] }, + environment: ProjectCreationEnvironment = {}, ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); @@ -674,7 +675,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool // skipped validation whenever `goalIds: []` and `goalId` arrive // together (goalIds wins resolution, mirroring the update path). const legacyGoalId = ids?.[0] ?? null; - const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null) as typeof projectData.executionWorkspacePolicy; + const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null, environment) as typeof projectData.executionWorkspacePolicy; return db.transaction(async (tx) => { const txDb = tx as unknown as Db; @@ -919,10 +920,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool }; }, - createWithRepositories: async (companyId: string, data: Parameters[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise => { + createWithRepositories: async (companyId: string, data: Parameters[1], repositories: import("@paperclipai/shared").ProjectRepository[], environment: ProjectCreationEnvironment = {}): Promise => { return db.transaction(async (tx) => { const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false }); - const project = await service.create(companyId, data); + const project = await service.create(companyId, data, environment); for (const repo of repositories) { await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } }); } diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index bd49cee9c63..ef08ba698eb 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -264,7 +264,7 @@ "packages/shared/src/home-paths.ts": "fa4f1c440653b76b3dbb7a51026d557287deb45d", "packages/shared/src/humanize-connection.test.ts": "ba9e90a44c63452b0bf9f733adb90241921e8eca", "packages/shared/src/humanize-connection.ts": "fbd414476a0ef7106a7c80d8572208781b091699", - "packages/shared/src/index.ts": "7ce86fb2ce04fa07ee4e742af137af2dfd10a3f3", + "packages/shared/src/index.ts": "a4ec3e9cca1eb3954d247eabd184b103ccefc2a2", "packages/shared/src/issue-activity-identity.test.ts": "3dc294640f54c2be67943e3e0149cf4eff2c95ca", "packages/shared/src/issue-activity-identity.ts": "1b0f07ec3fe0506aaaf80a4fa9893f3baf8f0075", "packages/shared/src/issue-attribution.test.ts": "84563685e7f3c48029243cf1cc24a3a09ee3cde9", @@ -397,14 +397,14 @@ "packages/shared/src/types/inbox-agent-policy.ts": "cb9d41d9681e80b834889268ad96015a39df92f6", "packages/shared/src/types/inbox-dismissal.ts": "5d2c71ce44ad77266d286ec1b1119ab1f0390a9d", "packages/shared/src/types/inbox-mine.ts": "1d302b531d34842189704abd2f75f8743d09490d", - "packages/shared/src/types/index.ts": "0f9b69e2285e600dbcbcc4e37acafa9bfed65b5c", + "packages/shared/src/types/index.ts": "01845e2a041eb892c3a193685471d635da2c1be9", "packages/shared/src/types/instance.ts": "2c40a18c05010991d93d719937faa4a43e47104f", "packages/shared/src/types/issue-tree-control.ts": "75489fffd491b8350b05caf57d7796419797d9df", "packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d", "packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec", "packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358", "packages/shared/src/types/plugin.ts": "07c409d7daca3fc9ad550538d4a7ecac03176a07", - "packages/shared/src/types/project.ts": "43a5232bdd1843301477403568ce0c9bc5e26de5", + "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", @@ -485,7 +485,7 @@ "packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53", "packages/shared/src/validators/plugin.test.ts": "e145e15cb98ffe35880b9f8eea88809910ed5886", "packages/shared/src/validators/plugin.ts": "318fac91061e0373f9acd7e9b29cb427dfdc4b7f", - "packages/shared/src/validators/project.ts": "61839f7a1b530f4d4e15df7546acf8b5f4a9843f", + "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/api/projects.ts b/ui/src/api/projects.ts index 0ff6ecab452..e2fb8738037 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -2,6 +2,7 @@ import type { Project, ProjectAccessMember, ProjectRepositoryOptions, + ProjectEnvironmentOptions, ProjectGitAuthorization, ProjectWorkspace, WorkspaceOperation, @@ -21,6 +22,7 @@ function projectPath(id: string, companyId?: string, suffix = "") { } export const projectsApi = { + environmentOptions: (companyId: string) => api.get(`/companies/${companyId}/project-environments`), gitAuthorization: (id: string) => api.get(projectPath(id, undefined, "/git-authorization")), setGitAuthorization: (id: string, grantId: string | null) => api.put(projectPath(id, undefined, "/git-authorization"), { grantId }), repositoryOptions: (companyId: string) => api.get(`/companies/${companyId}/project-repositories`), diff --git a/ui/src/components/NewProjectDialog.test.tsx b/ui/src/components/NewProjectDialog.test.tsx index f7dc764db16..0923f7a9ae9 100644 --- a/ui/src/components/NewProjectDialog.test.tsx +++ b/ui/src/components/NewProjectDialog.test.tsx @@ -6,10 +6,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PROJECT_COLORS } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { projectsApi } from "../api/projects"; +import { queryKeys } from "../lib/queryKeys"; import { getLastProjectId } from "../lib/recent-projects"; import { NewProjectForm } from "./NewProjectDialog"; -vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn() } })); +vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), environmentOptions: vi.fn() } })); vi.mock("./ProjectRepositoryInput", () => ({ ProjectRepositoryInput: () => null, repositoryOptionsKey: (companyId: string) => ["repositories", companyId], @@ -25,7 +26,8 @@ let root: ReturnType; let container: HTMLDivElement; beforeEach(() => { localStorage.clear(); - vi.mocked(projectsApi.create).mockReset(); + vi.mocked(projectsApi.create).mockReset().mockResolvedValue({ id: "project-created" } as Awaited>); + vi.mocked(projectsApi.environmentOptions).mockReset().mockResolvedValue({ groups: [], defaultEnvironmentId: null, error: null }); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -49,6 +51,7 @@ describe("new project colors", () => { act(() => root.render( )); + await settle(); const previewColor = container.querySelector('span[style]')!.style.backgroundColor; const input = container.querySelector('input[aria-label="Project name"]')!; act(() => { @@ -70,3 +73,108 @@ describe("new project colors", () => { expect(onClose).toHaveBeenCalledOnce(); }); }); + +const groups = [ + { storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "ssh", name: "Build host" }] }, + { storageProviderPluginId: "computer-plugin", environments: [{ id: "computer", name: "Agent computers" }] }, +]; + +async function renderForm() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + act(() => root.render( + + )); + await settle(); + const input = container.querySelector('input[aria-label="Project name"]')!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Environment project"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + return client; +} + +describe("project environment selection", () => { + it("groups compatible environments and follows the user's default within a group", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "ssh", error: null }); + vi.mocked(projectsApi.create).mockResolvedValue({ id: "project" } as Awaited>); + await renderForm(); + const select = container.querySelector("select")!; + expect(select.options).toHaveLength(2); + expect(select.options[0].textContent).toBe("Paperclip Sandbox, Build host"); + expect(select.value).toBe("sandbox"); + act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); + await settle(); + expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" })); + }); + + it("sends an explicit choice and preserves it after a create failure", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "computer", error: null }); + vi.mocked(projectsApi.create).mockRejectedValue(new Error("Try again")); + await renderForm(); + const select = container.querySelector("select")!; + expect(select.value).toBe("computer"); + act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); + await settle(); + expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "sandbox", expectedStorageProviderPluginId: null })); + expect(select.value).toBe("sandbox"); + expect(vi.mocked(projectsApi.create).mock.calls[0][1].executionWorkspacePolicy).toBeUndefined(); + }); + + it("hides the picker for compatible environments but still sends the default", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups: [groups[0]], defaultEnvironmentId: "ssh", error: null }); + await renderForm(); + expect(container.querySelector("select")).toBeNull(); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); + act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); + await settle(); + expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" })); + }); + + it("blocks submission while discovery is pending", async () => { + let resolve!: (value: Awaited>) => void; + vi.mocked(projectsApi.environmentOptions).mockReturnValue(new Promise(value => { resolve = value; })); + await renderForm(); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(true); + act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); + expect(projectsApi.create).not.toHaveBeenCalled(); + await act(async () => resolve({ groups, defaultEnvironmentId: "computer", error: null })); + await settle(); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); + }); + + + it("reports discovery failures and lets the user retry", async () => { + vi.mocked(projectsApi.environmentOptions).mockRejectedValueOnce(new Error("Environment lookup failed")) + .mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null }); + await renderForm(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Environment lookup failed"); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(true); + const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry")!; + act(() => retry.click()); + await settle(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); + }); + + it("keeps cached environment choices during a transient refetch failure", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null }) + .mockRejectedValue(new TypeError("Failed to fetch")); + const client = await renderForm(); + await act(async () => { await client.refetchQueries({ queryKey: queryKeys.projects.environments("company-1") }); }); + await settle(); + expect(container.querySelector("select")?.value).toBe("sandbox"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); + }); + + it("does not silently substitute storage when the default is unavailable", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: null, error: "Default is unavailable" }); + await renderForm(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Default is unavailable"); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(true); + const select = container.querySelector("select")!; + act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); + }); +}); diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 6e3d4e44a08..ce169136bf3 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { PROJECT_COLORS, type ProjectRepository } from "@paperclipai/shared"; import { X } from "lucide-react"; import { useDialog } from "../context/DialogContext"; @@ -10,6 +10,8 @@ import { queryKeys } from "../lib/queryKeys"; import { trackRecentProject } from "../lib/recent-projects"; import { ProjectTile } from "./ProjectTile"; import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"; +import { QueryErrorState, useQueryView } from "./QueryView"; +import { NativeSelect } from "./ui/select"; import { Button } from "./ui/button"; import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput"; import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; @@ -28,8 +30,16 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl const [repos, setRepos] = useState([]); const [connecting, setConnecting] = useState(false); const input = useRef(null); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const environments = useQuery({ queryKey: queryKeys.projects.environments(companyId), queryFn: () => projectsApi.environmentOptions(companyId), gcTime: 0 }); + const environmentView = useQueryView(environments); + const options = environmentView.data; + const environmentId = selectedEnvironmentId ?? options?.defaultEnvironmentId; + const selectedGroup = options?.groups.find(group => group.environments.some(environment => environment.id === environmentId)); + const environmentReady = Boolean(environmentView.kind !== "error" && options && (!options.error || selectedGroup) && (!options.groups.length || selectedGroup)); + const create = useMutation({ - mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id) }), + mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id), ...(selectedGroup ? { environmentId, expectedStorageProviderPluginId: selectedGroup.storageProviderPluginId } : {}) }), onSuccess: (project) => { trackRecentProject(project.id, companyId); void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) }); @@ -46,7 +56,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) }); setConnecting(false); }} /> - :
{ event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}> + : { event.preventDefault(); if (name.trim() && environmentReady && !create.isPending) create.mutate(); }}>
Create project @@ -59,12 +69,27 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
+ {options && (options.groups.length > 1 || (options.error && options.groups.length > 0)) &&
+ + setSelectedEnvironmentId(event.target.value)}> + {!selectedGroup && } + {options.groups.map(group => )} + +

These environments can share this project's working files. The storage choice stays with the project.

+
} + {(environmentView.kind === "loading" || environmentView.kind === "reconnecting") &&

Loading environments…

} + {environmentView.kind === "error" && } + {options?.error && !selectedGroup &&

{options.error}

} setConnecting(true)} disabled={create.isPending} />
{create.status === "error" &&

{describeError(create.error).body}

} -
+
- +
} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 5efb20c886f..8e0736915a8 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -517,6 +517,7 @@ export const queryKeys = { ["environment-custom-image-setup-sessions", sessionId] as const, }, projects: { + environments: (companyId: string) => ["project-environments", companyId] as const, all: (companyId: string) => ["projects", companyId] as const, list: (companyId: string, opts: { includeArchived?: boolean } = {}) => [ diff --git a/ui/storybook/stories/tasks/dialogs-modals.stories.tsx b/ui/storybook/stories/tasks/dialogs-modals.stories.tsx index ac852e9b58a..6cb9301f569 100644 --- a/ui/storybook/stories/tasks/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/tasks/dialogs-modals.stories.tsx @@ -335,6 +335,13 @@ function hydrateDialogQueries(queryClient: ReturnType) { queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), storybookAgents); queryClient.setQueryData(queryKeys.projects.list(COMPANY_ID), storybookProjects); + queryClient.setQueryData(queryKeys.projects.environments(COMPANY_ID), { + groups: [ + { storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "local", name: "Local" }] }, + { storageProviderPluginId: "computer-storage", environments: [{ id: "computers", name: "Agent computers" }] }, + ], + defaultEnvironmentId: "sandbox", error: null, + }); queryClient.setQueryData(queryKeys.goals.list(COMPANY_ID), storybookGoals); queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues); queryClient.setQueryData(queryKeys.issues.labels(COMPANY_ID), storybookIssueLabels);