Merge company endpoint deletion prerequisite into Discord scheduling

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 01:42:34 -05:00
commit 2802c361f3
4 files changed
+93 -5

No files matched your search

+25
View File
@@ -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));
},
};
+3 -1
View File
@@ -1,4 +1,5 @@
import { notifyChatCompanyCommandRegistration } from "./chat-channels/command-work-notifications.js";
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";
@@ -119,8 +120,9 @@ 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 agentLifecycleCompanyDeletion.deleteCompanyData(tx, id);
await notifyChatCompanyCommandRegistration(tx, id);
await chatCompanyDeletion.deleteCompanyData(tx, id);
await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id);
await notifyToolConnectionHealth(tx, id);
const rows = await tx
.delete(companies)