From 9f31816455828c9149c790db46ea82d1a6c77d31 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 13:20:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(M13):=20cron=20/api/cron/cleanup-agent-art?= =?UTF-8?q?ifacts=20=E2=80=94=20hard-delete=20FAILED/CANCELLED=20jobs=20>7?= =?UTF-8?q?=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/cron-cleanup-agent-artifacts.test.ts | 63 +++++++++++++++++++ app/api/cron/cleanup-agent-artifacts/route.ts | 24 +++++++ docs/API.md | 26 ++++++++ vercel.json | 4 ++ 4 files changed, 117 insertions(+) create mode 100644 __tests__/api/cron-cleanup-agent-artifacts.test.ts create mode 100644 app/api/cron/cleanup-agent-artifacts/route.ts diff --git a/__tests__/api/cron-cleanup-agent-artifacts.test.ts b/__tests__/api/cron-cleanup-agent-artifacts.test.ts new file mode 100644 index 0000000..188c558 --- /dev/null +++ b/__tests__/api/cron-cleanup-agent-artifacts.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeJob: { deleteMany: vi.fn() }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { POST } from '@/app/api/cron/cleanup-agent-artifacts/route' + +const mockPrisma = prisma as unknown as { + claudeJob: { deleteMany: ReturnType } +} + +const SECRET = 'test-cron-secret-abc123' + +function makeReq(headers: Record = {}): Request { + return new Request('http://localhost:3000/api/cron/cleanup-agent-artifacts', { + method: 'POST', + headers, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + process.env.CRON_SECRET = SECRET + mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 0 }) +}) + +describe('POST /api/cron/cleanup-agent-artifacts', () => { + it('401 zonder Authorization-header', async () => { + const res = await POST(makeReq()) + expect(res.status).toBe(401) + expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled() + }) + + it('401 met verkeerde secret', async () => { + const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' })) + expect(res.status).toBe(401) + expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled() + }) + + it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED ouder dan 7 dagen', async () => { + mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 5 }) + + const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.deleted).toBe(5) + expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) + + const arg = mockPrisma.claudeJob.deleteMany.mock.calls[0][0] + expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED'] }) + expect(arg.where.finished_at.lt).toBeInstanceOf(Date) + + // cutoff should be approximately 7 days ago + const cutoff = arg.where.finished_at.lt as Date + const diffMs = Date.now() - cutoff.getTime() + const diffDays = diffMs / (1000 * 60 * 60 * 24) + expect(diffDays).toBeCloseTo(7, 0) + }) +}) diff --git a/app/api/cron/cleanup-agent-artifacts/route.ts b/app/api/cron/cleanup-agent-artifacts/route.ts new file mode 100644 index 0000000..7dae4c4 --- /dev/null +++ b/app/api/cron/cleanup-agent-artifacts/route.ts @@ -0,0 +1,24 @@ +import { prisma } from '@/lib/prisma' + +export const runtime = 'nodejs' + +const CUTOFF_DAYS = 7 + +export async function POST(request: Request) { + const auth = request.headers.get('authorization') + const expected = process.env.CRON_SECRET + if (!expected || auth !== `Bearer ${expected}`) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const cutoff = new Date(Date.now() - CUTOFF_DAYS * 24 * 60 * 60 * 1000) + + const { count: deleted } = await prisma.claudeJob.deleteMany({ + where: { + status: { in: ['FAILED', 'CANCELLED'] }, + finished_at: { lt: cutoff }, + }, + }) + + return Response.json({ deleted, ran_at: new Date().toISOString() }) +} diff --git a/docs/API.md b/docs/API.md index c1c3345..bfda532 100644 --- a/docs/API.md +++ b/docs/API.md @@ -484,6 +484,32 @@ curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ --- +## Cron — Cleanup agent artifacts + +### `POST /api/cron/cleanup-agent-artifacts` + +Vercel cron handler die dagelijks draait. Verwijdert `FAILED` en `CANCELLED` claude_jobs waarvan `finished_at` ouder is dan 7 dagen. Hard-delete — geen historische waarde; audit-trail zit in git-commits. + +**Auth:** `Authorization: Bearer ${CRON_SECRET}` — zelfde mechanisme als `/api/cron/expire-questions`. Zonder secret of bij mismatch: 401. + +**Schedule:** `0 3 * * *` (dagelijks om 03:00 UTC). + +**Response 200:** +```json +{ + "deleted": 3, + "ran_at": "2026-05-01T03:00:00.000Z" +} +``` + +**Voorbeeld (handmatige trigger):** +```bash +curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + https://your-app.vercel.app/api/cron/cleanup-agent-artifacts +``` + +--- + ## Voorbeeldworkflow voor Claude Code 1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn. diff --git a/vercel.json b/vercel.json index 2ec90d6..311c925 100644 --- a/vercel.json +++ b/vercel.json @@ -4,6 +4,10 @@ { "path": "/api/cron/expire-questions", "schedule": "0 4 * * *" + }, + { + "path": "/api/cron/cleanup-agent-artifacts", + "schedule": "0 3 * * *" } ] }