diff --git a/server/src/__tests__/environment-runtime-driver-contract.test.ts b/server/src/__tests__/environment-runtime-driver-contract.test.ts index 373eec43e3b..f2616aae722 100644 --- a/server/src/__tests__/environment-runtime-driver-contract.test.ts +++ b/server/src/__tests__/environment-runtime-driver-contract.test.ts @@ -87,6 +87,7 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { }) { const companyId = randomUUID(); const agentId = randomUUID(); + const environmentId = randomUUID(); const runId = randomUUID(); const now = new Date(); let config = input.config; @@ -140,10 +141,10 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { where: (environment, { eq }) => eq(environment.driver, "local"), }) : null; - const environmentId = existingLocal?.id ?? randomUUID(); + const resolvedEnvironmentId = existingLocal?.id ?? environmentId; if (!existingLocal) { await db.insert(environments).values({ - id: environmentId, + id: resolvedEnvironmentId, name: `${input.driver} contract`, driver: input.driver, status: "active", @@ -169,7 +170,7 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { issueId: null, runId, environment: { - id: environmentId, + id: resolvedEnvironmentId, companyId, name: `${input.driver} contract`, description: null, diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index dd15546750d..c03dac9e41d 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -303,6 +303,66 @@ describeEmbeddedPostgres("environmentService leases", () => { expect((rows[0]?.metadata as Record)?.managedKubernetesSandbox).toBe(true); }); + it("returns a conflict when creating a second environment with the same name", async () => { + await seedEnvironment(); + + await svc.create({ + name: "Shared Fixture", + driver: "ssh", + status: "active", + config: { + host: "fixture.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, + }); + + await expect(svc.create({ + name: "Shared Fixture", + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:test", + reuseLease: false, + }, + })).rejects.toMatchObject({ + status: 409, + message: 'An environment named "Shared Fixture" already exists for this instance.', + }); + }); + + it("returns a conflict when renaming an environment to an existing name", async () => { + const { environmentId } = await seedEnvironment(); + const otherEnvironmentId = randomUUID(); + const now = new Date(); + + await db.insert(environments).values({ + id: otherEnvironmentId, + name: "Other Fixture", + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:test", + reuseLease: false, + }, + createdAt: now, + updatedAt: now, + }); + + await expect(svc.update(otherEnvironmentId, { + name: "Lease Fixture", + })).rejects.toMatchObject({ + status: 409, + message: 'An environment named "Lease Fixture" already exists for this instance.', + }); + + const original = await svc.getById(environmentId); + expect(original?.name).toBe("Lease Fixture"); + }); + it("rejects a second managed-sandbox row for the same company at the DB level", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 4b05f72d225..c746389a6c2 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -15,6 +15,7 @@ import { type EnvironmentLeaseStatus, type UpdateEnvironment, } from "@paperclipai/shared"; +import { conflict } from "../errors.js"; type EnvironmentRow = typeof environments.$inferSelect; type EnvironmentLeaseRow = typeof environmentLeases.$inferSelect; @@ -70,6 +71,18 @@ function readEnum(value: string | null, allowed: readonly T[], throw new Error(`Unexpected ${fieldName} value: ${value}`); } +function hasConstraintName(error: unknown, constraintName: string): boolean { + if (typeof error !== "object" || error === null) return false; + const candidate = error as { + constraint?: unknown; + constraint_name?: unknown; + cause?: unknown; + }; + return candidate.constraint === constraintName + || candidate.constraint_name === constraintName + || hasConstraintName(candidate.cause, constraintName); +} + function toEnvironment(row: EnvironmentRow): Environment { return { id: row.id, @@ -82,7 +95,7 @@ function toEnvironment(row: EnvironmentRow): Environment { metadata: cloneRecord(row.metadata), createdAt: row.createdAt, updatedAt: row.updatedAt, - } as unknown as Environment; + } as Environment; } type EnvironmentListFilters = { @@ -294,7 +307,16 @@ export function environmentService(db: Db) { sql`${environments.driver} = 'sandbox' AND (${environments.metadata} ->> 'managedByPaperclip')::boolean = true`, }) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error) => { + if ( + hasConstraintName(error, "environments_name_idx") + || hasConstraintName(error, "environments_managed_sandbox_idx") + ) { + return null; + } + throw error; + }); if (inserted) return toEnvironment(inserted); const winner = await db @@ -359,7 +381,16 @@ export function environmentService(db: Db) { updatedAt: now, }) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error) => { + if (hasConstraintName(error, "environments_name_idx")) { + throw conflict(`An environment named "${input.name}" already exists for this instance.`); + } + if (hasConstraintName(error, "environments_local_driver_idx")) { + throw conflict("A local environment already exists for this instance."); + } + throw error; + }); if (!row) { throw new Error("Failed to create environment"); } @@ -385,7 +416,16 @@ export function environmentService(db: Db) { .set(values) .where(eq(environments.id, id)) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error) => { + if (hasConstraintName(error, "environments_name_idx")) { + throw conflict(`An environment named "${patch.name}" already exists for this instance.`); + } + if (hasConstraintName(error, "environments_local_driver_idx")) { + throw conflict("A local environment already exists for this instance."); + } + throw error; + }); return row ? toEnvironment(row) : null; }, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index fb0470c0d99..91dab04264a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8461,8 +8461,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const resolvedInstanceSettings = await instanceSettings.get(); const environmentResolution = resolveExecutionWorkspaceEnvironmentId({ agentDefaultEnvironmentId: agent.defaultEnvironmentId, - instanceDefaultEnvironmentId: - (resolvedInstanceSettings as { defaultEnvironmentId?: string | null }).defaultEnvironmentId ?? null, + instanceDefaultEnvironmentId: resolvedInstanceSettings.defaultEnvironmentId ?? null, localDefaultEnvironmentId: localEnvironment.id, }); const shouldReuseExisting = requestedShouldReuseExisting;