mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(server): handle instance environment name conflicts
This commit is contained in:
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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user