diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index de60cba2a..604f4510b 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -233,7 +233,7 @@ USAGE gbrain jobs get gbrain jobs cancel gbrain jobs retry - gbrain jobs prune [--older-than 30d] + gbrain jobs prune [--older-than 30d] [--dry-run] gbrain jobs delete gbrain jobs stats gbrain jobs smoke @@ -633,8 +633,15 @@ HANDLER TYPES (built in) try { await queue.ensureSchema(); } catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); } - const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000) }); - console.log(`Pruned ${count} jobs older than ${days} days.`); + // #2712: --dry-run previews the count without deleting. It used to be + // silently ignored (the destructive default ran anyway). + const dryRun = hasFlag(args, '--dry-run'); + const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000), dryRun }); + if (dryRun) { + console.log(`[dry-run] Would prune ${count} jobs older than ${days} days. Nothing deleted.`); + } else { + console.log(`Pruned ${count} jobs older than ${days} days.`); + } break; } diff --git a/src/core/minions/queue.ts b/src/core/minions/queue.ts index 0d0780fdb..1c8334330 100644 --- a/src/core/minions/queue.ts +++ b/src/core/minions/queue.ts @@ -534,10 +534,21 @@ export class MinionQueue { } /** Prune old jobs in terminal statuses. Returns count of deleted rows. */ - async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[] }): Promise { + async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[]; dryRun?: boolean }): Promise { const statuses = opts?.status ?? ['completed', 'dead', 'cancelled']; const olderThan = opts?.olderThan ?? new Date(Date.now() - 30 * 86400000); + // #2712: dryRun counts the would-be-pruned rows without deleting. + // Silent-ignoring a safety flag on a delete path is data loss. + if (opts?.dryRun) { + const rows = await this.engine.executeRaw<{ count: string }>( + `SELECT count(*)::text as count FROM minion_jobs + WHERE status = ANY($1) AND updated_at < $2`, + [statuses, olderThan.toISOString()] + ); + return parseInt(rows[0]?.count ?? '0', 10); + } + const rows = await this.engine.executeRaw<{ count: string }>( `WITH pruned AS ( DELETE FROM minion_jobs diff --git a/test/minions.test.ts b/test/minions.test.ts index 90148361e..a76a56ed9 100644 --- a/test/minions.test.ts +++ b/test/minions.test.ts @@ -709,6 +709,26 @@ describe('MinionQueue: Prune', () => { const count = await queue.prune({ olderThan: new Date(Date.now() + 86400000) }); // future date = prune everything old enough expect(count).toBe(1); // only the cancelled one }); + + // #2712: --dry-run used to be silently ignored — the destructive default + // ran and deleted rows while the operator believed they were previewing. + test('dryRun counts prunable jobs without deleting', async () => { + const job1 = await queue.add('sync', {}); + await queue.cancelJob(job1.id); // terminal → prunable + + const wouldPrune = await queue.prune({ olderThan: new Date(Date.now() + 86400000), dryRun: true }); + expect(wouldPrune).toBe(1); + + // The row must still exist after a dry run. + const stillThere = await queue.getJob(job1.id); + expect(stillThere).not.toBeNull(); + expect(stillThere!.status).toBe('cancelled'); + + // A real prune afterwards actually deletes it. + const pruned = await queue.prune({ olderThan: new Date(Date.now() + 86400000) }); + expect(pruned).toBe(1); + expect(await queue.getJob(job1.id)).toBeNull(); + }); }); // --- Stats (1 test) ---