mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
fix(server): remove chat endpoints before company agents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f8ad26f0ff
commit
4d721402d2
4 files changed
+92
-4
No files matched your search
@@ -0,0 +1,25 @@
|
||||
# Company deletion with chat endpoints
|
||||
|
||||
Company deletion must remove chat endpoints before purging their assigned agents.
|
||||
The endpoint-to-agent foreign key uses RESTRICT, so waiting for the final company
|
||||
cascade fails while deleting agents. This was reproduced by the Discord scheduling
|
||||
producer test and by the owning company-removal suite.
|
||||
|
||||
The chat owner participates in the existing company-deletion transaction. It
|
||||
removes only the requested company's endpoint rows before the agent lifecycle
|
||||
participant runs. Existing endpoint foreign keys remove their dependent records.
|
||||
The lifecycle owner still requires completed agent termination. The transaction
|
||||
still rolls back all SQL cleanup if a later participant fails. No external calls,
|
||||
new scheduler, schema change, or relaxed constraint is introduced.
|
||||
|
||||
Discord command ownership tombstones have no cascading foreign keys and remain
|
||||
unchanged. Deletion does not authorize a new endpoint to adopt an old application.
|
||||
Other retained-history constraints, including voice-session restrictions, still
|
||||
apply; this fix does not bypass them or change external provider teardown policy.
|
||||
|
||||
Both new PostgreSQL regressions failed before the change. All six cleanup-removal
|
||||
cases pass afterward, including deletion ordering, another company's untouched
|
||||
endpoint, preserved namespace ownership, and rollback after a later failure.
|
||||
The suite accepts a validated dedicated external PostgreSQL test database because
|
||||
embedded PostgreSQL cannot start on this host. Server typecheck/build and remote
|
||||
CI/review are tracked separately; this is not evidence of deployed host sleep.
|
||||
@@ -1,8 +1,14 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { agentLifecycleCompanyDeletion } from "../modules/agent-lifecycle/company-deletion.js";
|
||||
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
chatEndpoints,
|
||||
chatDiscordCommandOwners,
|
||||
toolApplications,
|
||||
toolConnections,
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
@@ -26,7 +32,8 @@ import {
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import { companyService } from "../services/companies.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_CLEANUP_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
@@ -40,12 +47,16 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cleanup-removal-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cleanup-removal-");
|
||||
db = createDb(external ?? tempDb!.connectionString);
|
||||
configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(chatEndpoints);
|
||||
await db.delete(toolConnections);
|
||||
await db.delete(toolApplications);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueReadStates);
|
||||
@@ -62,6 +73,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
@@ -282,4 +294,44 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
await expect(db.select().from(agents).where(eq(agents.id, agentId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(companies).where(eq(companies.id, companyId))).resolves.toHaveLength(0);
|
||||
});
|
||||
async function seedEndpoint(companyId: string, agentId: string) {
|
||||
const [app] = await db.insert(toolApplications).values({ companyId, name: "Discord", type: "chat" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({
|
||||
companyId, applicationId: app.id, uid: randomUUID(), name: "Discord", transport: "chat_sdk", connectionPurpose: "channel",
|
||||
}).returning();
|
||||
const [endpoint] = await db.insert(chatEndpoints).values({
|
||||
companyId, connectionId: connection.id, provider: "discord", publicId: randomUUID(), assignedAgentId: agentId,
|
||||
}).returning();
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
it("deletes company endpoints before their agents while preserving other companies and ownership tombstones", async () => {
|
||||
const first = await seedFixture(), second = await seedFixture();
|
||||
const endpoint = await seedEndpoint(first.companyId, first.agentId);
|
||||
const other = await seedEndpoint(second.companyId, second.agentId);
|
||||
const [owner] = await db.insert(chatDiscordCommandOwners).values({
|
||||
applicationId: String(1_400_000_000_000_000_000n + BigInt("0x" + randomUUID().replaceAll("-", "").slice(0, 12))),
|
||||
companyId: first.companyId, endpointId: endpoint.id, actionId: randomUUID(),
|
||||
}).returning();
|
||||
await companyService(db).remove(first.companyId);
|
||||
expect(await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id))).toEqual([]);
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, first.companyId))).toEqual([]);
|
||||
expect(await db.select().from(companies).where(eq(companies.id, first.companyId))).toEqual([]);
|
||||
expect(await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, other.id))).toEqual([other]);
|
||||
expect(await db.select().from(chatDiscordCommandOwners).where(eq(chatDiscordCommandOwners.applicationId, owner.applicationId))).toEqual([owner]);
|
||||
});
|
||||
|
||||
it("rolls back endpoint cleanup when the later agent deletion fails", async () => {
|
||||
const f = await seedFixture();
|
||||
const endpoint = await seedEndpoint(f.companyId, f.agentId);
|
||||
vi.spyOn(agentLifecycleCompanyDeletion, "deleteCompanyData").mockImplementationOnce(async tx => {
|
||||
expect(await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id))).toEqual([]);
|
||||
throw new Error("agent deletion failed");
|
||||
});
|
||||
await expect(companyService(db).remove(f.companyId)).rejects.toThrow("agent deletion failed");
|
||||
expect(await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id))).toEqual([endpoint]);
|
||||
expect(await db.select().from(companies).where(eq(companies.id, f.companyId))).toHaveLength(1);
|
||||
expect(await db.select().from(agents).where(eq(agents.id, f.agentId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { chatEndpoints } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { CompanyDeletionParticipant } from "../../lib/company-deletion.js";
|
||||
|
||||
export const chatCompanyDeletion: CompanyDeletionParticipant = {
|
||||
async deleteCompanyData(tx, companyId) {
|
||||
await tx.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId));
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chatCompanyDeletion } from "./chat-channels/company-deletion.js";
|
||||
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
|
||||
import { notifyCompanyRetentionWork } from "./retention-work-notifications.js";
|
||||
import { notifyDecisionTargetWork } from "./decision-target-work.js";
|
||||
@@ -118,6 +119,7 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
await requestRestorePointDeletion(tx, { companyId: id });
|
||||
await tx.delete(executionWorkspaces).where(eq(executionWorkspaces.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await chatCompanyDeletion.deleteCompanyData(tx, id);
|
||||
await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id);
|
||||
await notifyToolConnectionHealth(tx, id);
|
||||
const rows = await tx
|
||||
|
||||
Reference in new issue
Block a user