Skip to content

Commit 876ed56

Browse files
committed
feat(webapp,dashboard-agent-db): retain and purge deleted agent chats
Soft-deleted chats are hard-deleted with their children after a 30d window by the maintenance sweep, and organization deletion enqueues a job that soft-deletes the org's chats so the same sweep removes them. The FK-free cascade lives in a single reusable deleteChatsByIds helper.
1 parent d6f67d4 commit 876ed56

7 files changed

Lines changed: 392 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Deleted assistant conversations are now permanently removed after a grace period, and deleting an organization also removes its conversations.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Retention for soft-deleted chats. A deleted chat is kept for a grace window and then
3+
* hard-deleted with all its child rows; one bounded statement per run, oldest first.
4+
* Also the eventual purge behind organization deletion, which soft-deletes the org's
5+
* chats so this same sweep removes them.
6+
*/
7+
8+
import {
9+
hardDeleteChatsSoftDeletedBefore,
10+
softDeleteChatsForOrganization,
11+
} from "@internal/dashboard-agent-db";
12+
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
13+
import { logger } from "~/services/logger.server";
14+
15+
/**
16+
* How long a soft-deleted chat is kept before it and its children are hard-deleted.
17+
* Long enough that an accidental delete can still be investigated; org deletion soft-
18+
* deletes the org's chats, so those are removed the same way once the window passes.
19+
*/
20+
export const CHAT_SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
21+
22+
/** Per-run cap. Retention is one bounded statement, not a row-at-a-time loop. */
23+
const RETENTION_BATCH_LIMIT = 500;
24+
25+
export type ChatRetentionResult = {
26+
/** Soft-deleted chats past the retention window dropped this run. */
27+
purged: number;
28+
failed: number;
29+
};
30+
31+
export type ChatRetentionDeps = {
32+
now?: () => Date;
33+
limit?: number;
34+
/** Hard-delete chats soft-deleted before `before`. Returns how many went. */
35+
purge?: (params: { before: Date; limit: number }) => Promise<number>;
36+
};
37+
38+
export async function sweepDashboardAgentSoftDeletedChats(
39+
deps: ChatRetentionDeps = {}
40+
): Promise<ChatRetentionResult> {
41+
const now = deps.now?.() ?? new Date();
42+
const limit = deps.limit ?? RETENTION_BATCH_LIMIT;
43+
const purge =
44+
deps.purge ?? ((params) => hardDeleteChatsSoftDeletedBefore(dashboardAgentDb, params));
45+
46+
const result: ChatRetentionResult = { purged: 0, failed: 0 };
47+
48+
try {
49+
result.purged = await purge({
50+
before: new Date(now.getTime() - CHAT_SOFT_DELETE_RETENTION_MS),
51+
limit,
52+
});
53+
} catch (error) {
54+
result.failed++;
55+
logger.error("Dashboard agent chat retention failed", { error });
56+
}
57+
58+
if (result.failed > 0) {
59+
throw new Error("The dashboard agent chat retention pass failed");
60+
}
61+
62+
return result;
63+
}
64+
65+
/**
66+
* Soft-delete every chat belonging to a deleted organization. The retention sweep above
67+
* hard-deletes them once the window passes, so the org-deletion request never runs a
68+
* cross-database hard delete.
69+
*/
70+
export async function purgeDashboardAgentChatsForOrganization(params: {
71+
organizationId: string;
72+
}): Promise<number> {
73+
return softDeleteChatsForOrganization(dashboardAgentDb, params);
74+
}

apps/webapp/app/services/deleteOrganization.server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { featuresForRequest } from "~/features.server";
55
import { DeleteProjectService } from "./deleteProject.server";
66
import { getCurrentPlan } from "./platform.v3.server";
77
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
8+
import { commonWorker } from "~/v3/commonWorker.server";
89

910
export class DeleteOrganizationService {
1011
#prismaClient: PrismaClient;
@@ -86,5 +87,13 @@ export class DeleteOrganizationService {
8687

8788
// runsEnabled + the org's projects (project.deletedAt) changed; drop all cached env rows.
8889
controlPlaneResolver.invalidateOrganization(organization.id);
90+
91+
// Soft-delete the org's dashboard agent chats; retention purges them later. Enqueued,
92+
// not inline: the agent store is a separate database in cloud.
93+
await commonWorker.enqueue({
94+
id: `dashboardAgent.purgeOrganization:${organization.id}`,
95+
job: "dashboardAgent.purgeOrganization",
96+
payload: { organizationId: organization.id },
97+
});
8998
}
9099
}

apps/webapp/app/v3/commonWorker.server.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
runAttioUserSync,
1212
runAttioWorkspaceSync,
1313
} from "~/services/attio.server";
14+
import {
15+
purgeDashboardAgentChatsForOrganization,
16+
sweepDashboardAgentSoftDeletedChats,
17+
} from "~/services/dashboardAgentChatRetention.server";
1418
import { sweepDashboardAgentTurnEvals } from "~/services/dashboardAgentEvalRetention.server";
1519
import { sweepDashboardAgentInvestigations } from "~/services/dashboardAgentInvestigationSweep.server";
1620
import { logger } from "~/services/logger.server";
@@ -162,6 +166,16 @@ function initializeWorker() {
162166
maxAttempts: 1,
163167
},
164168
},
169+
// Soft-deletes a deleted organization's chats; the maintenance sweep purges them.
170+
"dashboardAgent.purgeOrganization": {
171+
schema: z.object({
172+
organizationId: z.string(),
173+
}),
174+
visibilityTimeoutMs: 60_000,
175+
retry: {
176+
maxAttempts: 5,
177+
},
178+
},
165179
},
166180
concurrency: {
167181
workers: env.COMMON_WORKER_CONCURRENCY_WORKERS,
@@ -243,8 +257,29 @@ function initializeWorker() {
243257
failure ??= error;
244258
}
245259

260+
// Hard-delete chats soft-deleted past the retention window, with their children.
261+
try {
262+
const chats = await sweepDashboardAgentSoftDeletedChats();
263+
if (chats.purged > 0) {
264+
logger.debug("Dashboard agent chat retention", chats);
265+
}
266+
} catch (error) {
267+
failure ??= error;
268+
}
269+
246270
if (failure) throw failure;
247271
},
272+
"dashboardAgent.purgeOrganization": async ({ payload }) => {
273+
const soft = await purgeDashboardAgentChatsForOrganization({
274+
organizationId: payload.organizationId,
275+
});
276+
if (soft > 0) {
277+
logger.debug("Dashboard agent organization purge", {
278+
organizationId: payload.organizationId,
279+
softDeleted: soft,
280+
});
281+
}
282+
},
248283
},
249284
});
250285

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
createChat,
3+
createDashboardAgentDb,
4+
type DashboardAgentDb,
5+
type DashboardAgentDbClient,
6+
} from "@internal/dashboard-agent-db";
7+
import { postgresTest } from "@internal/testcontainers";
8+
import type { PrismaClient } from "@trigger.dev/database";
9+
import { readdirSync, readFileSync } from "node:fs";
10+
import path from "node:path";
11+
import { afterEach, describe, expect, vi } from "vitest";
12+
13+
const ctx = vi.hoisted(() => ({
14+
agentDb: undefined as unknown as DashboardAgentDb,
15+
}));
16+
17+
vi.mock("~/services/dashboardAgentDb.server", () => ({
18+
get dashboardAgentDb() {
19+
return ctx.agentDb;
20+
},
21+
}));
22+
23+
const { sweepDashboardAgentSoftDeletedChats, purgeDashboardAgentChatsForOrganization } =
24+
await import("~/services/dashboardAgentChatRetention.server");
25+
26+
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
27+
async function applyAgentSchema(prisma: PrismaClient) {
28+
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
29+
const migrations = readdirSync(folder)
30+
.filter((file) => file.endsWith(".sql"))
31+
.sort();
32+
for (const name of migrations) {
33+
const sql = readFileSync(path.join(folder, name), "utf8");
34+
for (const statement of sql.split("--> statement-breakpoint")) {
35+
const trimmed = statement.trim();
36+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
37+
}
38+
}
39+
}
40+
41+
let agentDbClient: DashboardAgentDbClient | undefined;
42+
let prismaForRaw: PrismaClient | undefined;
43+
44+
async function boot(prisma: PrismaClient, connectionUri: string) {
45+
await applyAgentSchema(prisma);
46+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
47+
ctx.agentDb = agentDbClient.db;
48+
prismaForRaw = prisma;
49+
}
50+
51+
afterEach(async () => {
52+
await agentDbClient?.close();
53+
agentDbClient = undefined;
54+
});
55+
56+
const ORG = "org_ret";
57+
const USER = "user_ret";
58+
59+
/** One row in every chatId-keyed table, so a delete that misses one leaves a leak. */
60+
async function seedChatWithChildren(id: string) {
61+
await createChat(ctx.agentDb, { id, organizationId: ORG, userId: USER });
62+
const raw = prismaForRaw!;
63+
await raw.$executeRawUnsafe(
64+
`insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message)
65+
values ($1, $1 || '-m', 1, 'user', '{}'::jsonb)`,
66+
id
67+
);
68+
await raw.$executeRawUnsafe(
69+
`insert into trigger_dashboard_agent.chat_sessions (chat_id, public_access_token) values ($1, 'pat')`,
70+
id
71+
);
72+
await raw.$executeRawUnsafe(
73+
`insert into trigger_dashboard_agent.chat_turn_evals (chat_id, turn, organization_id, user_id)
74+
values ($1, 0, $2, $3)`,
75+
id,
76+
ORG,
77+
USER
78+
);
79+
await raw.$executeRawUnsafe(
80+
`insert into trigger_dashboard_agent.investigations (id, chat_id, project_ref, environment_ref, state)
81+
values ($1 || '-inv', $1, 'proj', 'env', '{"outcome":"in_progress"}'::jsonb)`,
82+
id
83+
);
84+
await raw.$executeRawUnsafe(
85+
`insert into trigger_dashboard_agent.watches
86+
(id, chat_id, identity, spec, organization_id, project_id, environment_id, user_id, expires_at)
87+
values ($1 || '-w', $1, 'ident', '{}'::jsonb, $2, 'proj', 'env', $3, now() + interval '1 day')`,
88+
id,
89+
ORG,
90+
USER
91+
);
92+
await raw.$executeRawUnsafe(
93+
`insert into trigger_dashboard_agent.watch_submissions
94+
(chat_id, client_request_id, organization_id, user_id, project_id, environment_id, draft_hash, draft)
95+
values ($1, 'req', $2, $3, 'proj', 'env', 'hash', '{}'::jsonb)`,
96+
id,
97+
ORG,
98+
USER
99+
);
100+
}
101+
102+
async function setDeletedAtDaysAgo(id: string, days: number) {
103+
await prismaForRaw!.$executeRawUnsafe(
104+
`update trigger_dashboard_agent.chats set deleted_at = now() - ($2 || ' days')::interval where id = $1`,
105+
id,
106+
String(days)
107+
);
108+
}
109+
110+
const CHILD_TABLES = [
111+
"chat_messages",
112+
"chat_sessions",
113+
"chat_turn_evals",
114+
"investigations",
115+
"watches",
116+
"watch_submissions",
117+
];
118+
119+
async function rowCounts(id: string): Promise<Record<string, number>> {
120+
const counts: Record<string, number> = {};
121+
const chat = await prismaForRaw!.$queryRawUnsafe<{ n: bigint }[]>(
122+
`select count(*)::int as n from trigger_dashboard_agent.chats where id = $1`,
123+
id
124+
);
125+
counts.chats = Number(chat[0]!.n);
126+
for (const table of CHILD_TABLES) {
127+
const rows = await prismaForRaw!.$queryRawUnsafe<{ n: number }[]>(
128+
`select count(*)::int as n from trigger_dashboard_agent.${table} where chat_id = $1`,
129+
id
130+
);
131+
counts[table] = Number(rows[0]!.n);
132+
}
133+
return counts;
134+
}
135+
136+
describe("the dashboard agent chat retention sweep", () => {
137+
postgresTest(
138+
"hard-deletes only chats soft-deleted past the window, with every child row",
139+
async ({ prisma, postgresContainer }) => {
140+
await boot(prisma, postgresContainer.getConnectionUri());
141+
142+
await seedChatWithChildren("chat_old");
143+
await setDeletedAtDaysAgo("chat_old", 40); // past the 30d window
144+
145+
await seedChatWithChildren("chat_recent");
146+
await setDeletedAtDaysAgo("chat_recent", 1); // inside the window
147+
148+
await seedChatWithChildren("chat_live"); // never deleted
149+
150+
const result = await sweepDashboardAgentSoftDeletedChats();
151+
expect(result).toEqual({ purged: 1, failed: 0 });
152+
153+
const old = await rowCounts("chat_old");
154+
for (const table of ["chats", ...CHILD_TABLES]) {
155+
expect(old[table], `${table} should be empty for the purged chat`).toBe(0);
156+
}
157+
158+
const recent = await rowCounts("chat_recent");
159+
const live = await rowCounts("chat_live");
160+
for (const table of ["chats", ...CHILD_TABLES]) {
161+
expect(recent[table], `${table} kept for the in-window chat`).toBe(1);
162+
expect(live[table], `${table} kept for the live chat`).toBe(1);
163+
}
164+
},
165+
30_000
166+
);
167+
168+
postgresTest(
169+
"org purge soft-deletes the org's chats and leaves other orgs alone",
170+
async ({ prisma, postgresContainer }) => {
171+
await boot(prisma, postgresContainer.getConnectionUri());
172+
173+
await createChat(ctx.agentDb, { id: "chat_a", organizationId: ORG, userId: USER });
174+
await createChat(ctx.agentDb, { id: "chat_b", organizationId: ORG, userId: USER });
175+
await createChat(ctx.agentDb, {
176+
id: "chat_other",
177+
organizationId: "org_other",
178+
userId: USER,
179+
});
180+
181+
const soft = await purgeDashboardAgentChatsForOrganization({ organizationId: ORG });
182+
expect(soft).toBe(2);
183+
184+
const deleted = await prisma.$queryRawUnsafe<{ id: string; deleted: boolean }[]>(
185+
`select id, (deleted_at is not null) as deleted from trigger_dashboard_agent.chats order by id`
186+
);
187+
const byId = Object.fromEntries(deleted.map((row) => [row.id, row.deleted]));
188+
expect(byId).toEqual({ chat_a: true, chat_b: true, chat_other: false });
189+
190+
// Idempotent: a second call touches nothing already soft-deleted.
191+
expect(await purgeDashboardAgentChatsForOrganization({ organizationId: ORG })).toBe(0);
192+
}
193+
);
194+
});

0 commit comments

Comments
 (0)