diff --git a/server/src/__tests__/cleanup-removal-service.test.ts b/server/src/__tests__/cleanup-removal-service.test.ts new file mode 100644 index 00000000..f74f3798 --- /dev/null +++ b/server/src/__tests__/cleanup-removal-service.test.ts @@ -0,0 +1,188 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + companySkills, + createDb, + heartbeatRuns, + issueComments, + issueExecutionDecisions, + issueReadStates, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { agentService } from "../services/agents.ts"; +import { companyService } from "../services/companies.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping cleanup removal service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("cleanup removal services", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cleanup-removal-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(issueReadStates); + await db.delete(issueComments); + await db.delete(issueExecutionDecisions); + await db.delete(companySkills); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedFixture() { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Regression fixture", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + createdByUserId: "user-1", + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "completed", + contextSnapshot: { issueId }, + }); + + return { agentId, companyId, issueId, runId }; + } + + it("removes agent-owned issue comments and run-linked activity before deleting the agent", async () => { + const { agentId, companyId, issueId, runId } = await seedFixture(); + + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorAgentId: agentId, + body: "Agent-authored comment", + }); + + await db.insert(activityLog).values({ + id: randomUUID(), + companyId, + actorType: "agent", + actorId: agentId, + action: "heartbeat.completed", + entityType: "issue", + entityId: issueId, + runId, + details: {}, + }); + + await db.insert(issueExecutionDecisions).values({ + id: randomUUID(), + companyId, + issueId, + stageId: randomUUID(), + stageType: "review", + actorAgentId: agentId, + outcome: "approved", + body: "Looks good", + createdByRunId: runId, + }); + + const removed = await agentService(db).remove(agentId); + + expect(removed?.id).toBe(agentId); + await expect(db.select().from(agents).where(eq(agents.id, agentId))).resolves.toHaveLength(0); + await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0); + await expect(db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).resolves.toHaveLength(0); + await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0); + }); + + it("removes issue read states and activity rows before deleting the company", async () => { + const { companyId, issueId, runId } = await seedFixture(); + + await db.insert(issueReadStates).values({ + id: randomUUID(), + companyId, + issueId, + userId: "user-1", + }); + + await db.insert(companySkills).values({ + id: randomUUID(), + companyId, + key: "paperclipai/paperclip/paperclip", + slug: "paperclip", + name: "Paperclip", + markdown: "# Paperclip", + }); + + await db.insert(activityLog).values({ + id: randomUUID(), + companyId, + actorType: "system", + actorId: "system", + action: "run.created", + entityType: "run", + entityId: runId, + runId, + details: {}, + }); + + const removed = await companyService(db).remove(companyId); + + expect(removed?.id).toBe(companyId); + await expect(db.select().from(companies).where(eq(companies.id, companyId))).resolves.toHaveLength(0); + await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toHaveLength(0); + await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0); + await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0); + }); +}); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 17d2e46d..abba7414 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -8,9 +8,13 @@ import { agentRuntimeState, agentTaskSessions, agentWakeupRequests, + activityLog, costEvents, heartbeatRunEvents, heartbeatRuns, + issueExecutionDecisions, + issues, + issueComments, } from "@paperclipai/db"; import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; @@ -474,8 +478,20 @@ export function agentService(db: Db) { return db.transaction(async (tx) => { await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); + await tx + .update(issues) + .set({ assigneeAgentId: null, createdByAgentId: null }) + .where(or(eq(issues.assigneeAgentId, id), eq(issues.createdByAgentId, id))); await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id)); await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id)); + await tx.delete(activityLog).where( + or( + eq(activityLog.agentId, id), + sql`${activityLog.runId} in (select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${id})`, + ), + ); + await tx.delete(issueExecutionDecisions).where(eq(issueExecutionDecisions.actorAgentId, id)); + await tx.delete(issueComments).where(eq(issueComments.authorAgentId, id)); await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id)); await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id)); await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id)); diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 1d23dab2..dcdeb27d 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -17,6 +17,7 @@ import { heartbeatRunEvents, costEvents, financeEvents, + issueReadStates, approvalComments, approvals, activityLog, @@ -25,6 +26,7 @@ import { invites, principalPermissionGrants, companyMemberships, + companySkills, } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; @@ -260,6 +262,7 @@ export function companyService(db: Db) { // Delete from child tables in dependency order await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id)); await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id)); + await tx.delete(activityLog).where(eq(activityLog.companyId, id)); await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id)); await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id)); await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id)); @@ -274,13 +277,14 @@ export function companyService(db: Db) { await tx.delete(invites).where(eq(invites.companyId, id)); await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id)); await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id)); + await tx.delete(companySkills).where(eq(companySkills.companyId, id)); + await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id)); await tx.delete(issues).where(eq(issues.companyId, id)); await tx.delete(companyLogos).where(eq(companyLogos.companyId, id)); await tx.delete(assets).where(eq(assets.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id)); await tx.delete(projects).where(eq(projects.companyId, id)); await tx.delete(agents).where(eq(agents.companyId, id)); - await tx.delete(activityLog).where(eq(activityLog.companyId, id)); const rows = await tx .delete(companies) .where(eq(companies.id, id)) diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index a89fe114..c5ab59d8 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -22,18 +22,9 @@ const TASK_TITLE = "E2E test task"; test.describe("Onboarding wizard", () => { test("completes full wizard flow", async ({ page }) => { - await page.goto("/"); + await page.goto("/onboarding"); const wizardHeading = page.locator("h3", { hasText: "Name your company" }); - const newCompanyBtn = page.getByRole("button", { name: "New Company" }); - - await expect( - wizardHeading.or(newCompanyBtn) - ).toBeVisible({ timeout: 15_000 }); - - if (await newCompanyBtn.isVisible()) { - await newCompanyBtn.click(); - } await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); @@ -45,7 +36,7 @@ test.describe("Onboarding wizard", () => { await expect( page.locator("h3", { hasText: "Create your first agent" }) - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible({ timeout: 30_000 }); const agentNameInput = page.locator('input[placeholder="CEO"]'); await expect(agentNameInput).toHaveValue(AGENT_NAME); @@ -61,7 +52,7 @@ test.describe("Onboarding wizard", () => { await expect( page.locator("h3", { hasText: "Give it something to do" }) - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible({ timeout: 30_000 }); const taskTitleInput = page.locator( 'input[placeholder="e.g. Research competitor pricing"]' @@ -73,7 +64,7 @@ test.describe("Onboarding wizard", () => { await expect( page.locator("h3", { hasText: "Ready to launch" }) - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible({ timeout: 30_000 }); await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); @@ -81,7 +72,7 @@ test.describe("Onboarding wizard", () => { await page.getByRole("button", { name: "Create & Open Issue" }).click(); - await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); + await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 }); const baseUrl = page.url().split("/").slice(0, 3).join("/");