Skip to content

Commit 7dc74b7

Browse files
committed
fix(dashboard-agent): cover the chat delete cascade and warn on the batch cap
Ports the hard-delete cascade test back from the webapp suite, logs when a retention pass ends on a full batch, and drops the unused exports.
1 parent c2acddb commit 7dc74b7

3 files changed

Lines changed: 94 additions & 7 deletions

File tree

internal-packages/dashboard-agent/GUIDEBOOK.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,14 @@ A sweep (`dashboardAgentWatchSweep.server.ts`) is the backstop:
380380
- a row still active **2 minutes** past `expiresAt` is finalized;
381381
- a resolved row whose wake is still owed **5 minutes** later is redelivered —
382382
the sweep can't tell whether the user was already told, so delivery is
383-
id-deduped rather than conditional;
384-
- terminal rows are kept **7 days**; the outcome also lives in the transcript.
383+
id-deduped rather than conditional.
385384

386385
The agent project runs two scheduled tasks of its own: `dashboard-agent-investigation-sweep`
387386
every 5 minutes settles cards left `in_progress` past **30 minutes**
388387
(`investigation-sweep.ts`), and `dashboard-agent-maintenance` at 03:00 daily is
389388
retention (`maintenance.ts`) — judged turns and soft-deleted chats past **30
390-
days**, terminal watches and their submission ledger past **7 days**. Retention
389+
days**, terminal watches and their submission ledger past **7 days** — a purged
390+
watch outcome still lives in the transcript. Retention
391391
does nothing at all without `DASHBOARD_AGENT_DATABASE_URL`: it never falls back
392392
to another database.
393393

internal-packages/dashboard-agent/src/maintenance.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,64 @@ async function seedSubmission(chatId: string, requestId: string, ageDays: number
7474
);
7575
}
7676

77+
/** One row in every chatId-keyed table, so a delete that misses one leaves a leak. */
78+
async function seedChatWithChildren(db: DashboardAgentDb, id: string) {
79+
await createChat(db, { id, organizationId: ORG, userId: USER });
80+
await raw(
81+
`insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message)
82+
values ($1, $1 || '-m', 1, 'user', '{}'::jsonb)`,
83+
[id]
84+
);
85+
await raw(
86+
`insert into trigger_dashboard_agent.chat_sessions (chat_id, public_access_token) values ($1, 'pat')`,
87+
[id]
88+
);
89+
await raw(
90+
`insert into trigger_dashboard_agent.chat_turn_evals (chat_id, turn, organization_id, user_id)
91+
values ($1, 0, $2, $3)`,
92+
[id, ORG, USER]
93+
);
94+
await raw(
95+
`insert into trigger_dashboard_agent.investigations (id, chat_id, project_ref, environment_ref, state)
96+
values ($1 || '-inv', $1, 'proj', 'env', '{"outcome":"in_progress"}'::jsonb)`,
97+
[id]
98+
);
99+
await raw(
100+
`insert into trigger_dashboard_agent.watches
101+
(id, chat_id, identity, spec, organization_id, project_id, environment_id, user_id, expires_at)
102+
values ($1 || '-w', $1, 'ident', '{}'::jsonb, $2, 'proj', 'env', $3, now() + interval '1 day')`,
103+
[id, ORG, USER]
104+
);
105+
await raw(
106+
`insert into trigger_dashboard_agent.watch_submissions
107+
(chat_id, client_request_id, organization_id, user_id, project_id, environment_id, draft_hash, draft)
108+
values ($1, 'req', $2, $3, 'proj', 'env', 'hash', '{}'::jsonb)`,
109+
[id, ORG, USER]
110+
);
111+
}
112+
113+
const CHILD_TABLES = [
114+
"chat_messages",
115+
"chat_sessions",
116+
"chat_turn_evals",
117+
"investigations",
118+
"watches",
119+
"watch_submissions",
120+
];
121+
122+
async function rowCounts(id: string): Promise<Record<string, number>> {
123+
const counts: Record<string, number> = {};
124+
for (const table of ["chats", ...CHILD_TABLES]) {
125+
const column = table === "chats" ? "id" : "chat_id";
126+
const rows = await raw(
127+
`select count(*)::int as n from trigger_dashboard_agent.${table} where ${column} = $1`,
128+
[id]
129+
);
130+
counts[table] = Number((rows as unknown as { n: number }[])[0]!.n);
131+
}
132+
return counts;
133+
}
134+
77135
async function count(table: string): Promise<number> {
78136
const rows = await raw(`select count(*)::int as n from trigger_dashboard_agent.${table}`);
79137
return Number((rows as unknown as { n: number }[])[0]!.n);
@@ -136,6 +194,32 @@ describe("the dashboard agent retention pass", () => {
136194
60_000
137195
);
138196

197+
postgresTest(
198+
"a purged chat takes every chatId-keyed child row with it",
199+
async ({ postgresContainer }) => {
200+
const db = await boot(postgresContainer.getConnectionUri());
201+
202+
await seedChatWithChildren(db, "chat_cascade_old");
203+
await seedChatWithChildren(db, "chat_cascade_new");
204+
await raw(
205+
`update trigger_dashboard_agent.chats set deleted_at = now() - interval '40 days' where id = 'chat_cascade_old'`
206+
);
207+
await raw(
208+
`update trigger_dashboard_agent.chats set deleted_at = now() - interval '1 day' where id = 'chat_cascade_new'`
209+
);
210+
211+
expect(await runDashboardAgentRetention(db)).toMatchObject({ chats: 1 });
212+
213+
const purged = await rowCounts("chat_cascade_old");
214+
const kept = await rowCounts("chat_cascade_new");
215+
for (const table of ["chats", ...CHILD_TABLES]) {
216+
expect(purged[table], `${table} should be empty for the purged chat`).toBe(0);
217+
expect(kept[table], `${table} kept for the in-window chat`).toBe(1);
218+
}
219+
},
220+
60_000
221+
);
222+
139223
postgresTest(
140224
"an active watch is never purged, however old it is",
141225
async ({ postgresContainer }) => {

internal-packages/dashboard-agent/src/maintenance.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,16 @@ export const TURN_EVAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
2626
* enough that an accidental delete can still be investigated; organization deletion soft-
2727
* deletes the org's chats, so those are removed the same way once the window passes.
2828
*/
29-
export const CHAT_SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
29+
const CHAT_SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
3030

3131
/** How long a terminal watch and its submission ledger are kept. */
32-
export const WATCH_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
32+
const WATCH_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
3333

3434
/** Per-statement cap. */
35-
export const RETENTION_BATCH_LIMIT = 500;
35+
const RETENTION_BATCH_LIMIT = 500;
3636

3737
/** Cap on the statements one pass may run, so a huge backlog can't run forever. */
38-
export const MAX_RETENTION_BATCHES = 20;
38+
const MAX_RETENTION_BATCHES = 20;
3939

4040
export type RetentionResult = {
4141
turnEvals: number;
@@ -89,6 +89,9 @@ export async function runDashboardAgentRetention(
8989
const deleted = await purge({ before, limit });
9090
total += deleted;
9191
if (deleted < limit) break;
92+
if (batch === maxBatches - 1) {
93+
logger.warn(`dashboard-agent retention hit the batch cap: ${name}`, { total, before });
94+
}
9295
}
9396
} catch (error) {
9497
failed.push(name);

0 commit comments

Comments
 (0)