fix(server): handle instance environment name conflicts

This commit is contained in:
Devin Foley committed 2026-06-20 00:11:03 -07:00
1 parent 039097f951
commit 7a60924aa5
4 files changed
+109 -9

No files matched your search

@@ -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,
@@ -303,6 +303,66 @@ describeEmbeddedPostgres("environmentService leases", () => {
expect((rows[0]?.metadata as Record<string, unknown>)?.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({
+44 -4
View File
@@ -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<T extends string>(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;
},
+1 -2
View File
@@ -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;