From 9794a9baef34acb34634e585d69f4e2f1c86ea25 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 13:42:18 +0200 Subject: [PATCH] M13: Veilige Claude-agent-workflow (Scrum4Me-side) (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add pushed_at field to ClaudeJob schema Nullable DateTime column to record when the agent's feature branch was pushed to origin. Enables the UI to show a 'pushed' state independently of DONE status. Co-Authored-By: Claude Sonnet 4.6 * feat: GitHub-link op DONE-card + pushed_at doorvoer - lib/job-status-url.ts: getBranchUrl(repoUrl, branch) → GitHub tree URL - JobState + ClaudeJobEvent: pushed_at? veld toegevoegd - realtime/solo/route.ts: pushed_at in Prisma-select, JobPayload en mapping - SoloBoardProps + TaskDetailDialog: repoUrl prop doorgevoerd - task-detail-dialog: "Open op GitHub"-link als done + pushed_at + branch + repoUrl - 3 unit-tests voor getBranchUrl; totaal 261 tests groen Co-Authored-By: Claude Sonnet 4.6 * feat: add VerifyResult enum, verify_only on Task, verify_result on ClaudeJob Co-Authored-By: Claude Sonnet 4.6 * feat: add verify_result+pushed_at to JobState, VerifyResultApi type, SSE payload Co-Authored-By: Claude Sonnet 4.6 * feat: verify_only field on SoloTask, PATCH route saves verify_only Co-Authored-By: Claude Sonnet 4.6 * feat: TaskDetailDialog — verify_result display + verify_only checkbox Co-Authored-By: Claude Sonnet 4.6 * test: verify_only PATCH + verify_result dialog render + store fix Co-Authored-By: Claude Sonnet 4.6 * docs: document VerifyResult enum, verify_only task field, pushed_at in architecture Co-Authored-By: Claude Sonnet 4.6 * feat(M13): cron /api/cron/cleanup-agent-artifacts — hard-delete FAILED/CANCELLED jobs >7 days * feat(M13): add auto_pr field to Product schema + migration * feat(M13): auto_pr toggle in product settings — server action + UI component + tests * feat(M13): add pr_url to ClaudeJob schema + migration * feat(M13): UI — 'Open PR' link on DONE-card; pr_url in JobState + SSE + task-dialog * feat(M13): add retry_count migration + regen erd - Migration ALTER TABLE claude_jobs ADD COLUMN retry_count INT DEFAULT 0 (schema.prisma was reeds bijgewerkt in eerdere commits) - docs/erd.svg geregenereerd voor de complete M13-schema-wijzigingen (verify_result, verify_only, pushed_at, pr_url, auto_pr, retry_count) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- .../api/cron-cleanup-agent-artifacts.test.ts | 63 +++++ __tests__/api/tasks.test.ts | 29 +++ .../products/auto-pr-toggle.test.tsx | 46 ++++ .../solo/task-detail-dialog.test.tsx | 228 ++++++++++++++++++ __tests__/lib/job-status-url.test.ts | 22 ++ __tests__/stores/solo-store-realtime.test.ts | 1 + actions/products.ts | 16 ++ app/(app)/products/[id]/settings/page.tsx | 11 + app/(app)/products/[id]/solo/page.tsx | 2 + app/api/cron/cleanup-agent-artifacts/route.ts | 24 ++ app/api/realtime/solo/route.ts | 8 +- app/api/tasks/[id]/route.ts | 30 ++- components/products/auto-pr-toggle.tsx | 55 +++++ components/solo/solo-board.tsx | 5 +- components/solo/task-detail-dialog.tsx | 112 ++++++++- docs/API.md | 26 ++ docs/erd.svg | 2 +- docs/scrum4me-architecture.md | 16 +- lib/job-status-url.ts | 4 + .../migration.sql | 8 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 12 + stores/solo-store.ts | 15 +- vercel.json | 4 + 26 files changed, 725 insertions(+), 20 deletions(-) create mode 100644 __tests__/api/cron-cleanup-agent-artifacts.test.ts create mode 100644 __tests__/components/products/auto-pr-toggle.test.tsx create mode 100644 __tests__/components/solo/task-detail-dialog.test.tsx create mode 100644 __tests__/lib/job-status-url.test.ts create mode 100644 app/api/cron/cleanup-agent-artifacts/route.ts create mode 100644 components/products/auto-pr-toggle.tsx create mode 100644 lib/job-status-url.ts create mode 100644 prisma/migrations/20260501100629_add_verify_result_and_verify_only/migration.sql create mode 100644 prisma/migrations/20260501111511_add_claude_job_retry_count/migration.sql create mode 100644 prisma/migrations/20260501112415_add_product_auto_pr/migration.sql create mode 100644 prisma/migrations/20260501112642_add_claude_job_pr_url/migration.sql 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/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index ed0616e..3b08da7 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -200,6 +200,35 @@ describe('PATCH /api/tasks/:id', () => { }) }) + // TC-T-12 + it('updates verify_only alone and returns 200 with verify_only in response', async () => { + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: null, verify_only: true }) + + const res = await patchTask(...makeRequest({ verify_only: true })) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.verify_only).toBe(true) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { verify_only: true } }), + ) + }) + + it('combines verify_only and implementation_plan into one update call', async () => { + const plan = 'Verify only: check test results.' + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: plan, verify_only: true }) + + const res = await patchTask(...makeRequest({ implementation_plan: plan, verify_only: true })) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data).toMatchObject({ implementation_plan: plan, verify_only: true }) + expect(mockPrisma.task.update).toHaveBeenCalledTimes(1) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { implementation_plan: plan, verify_only: true } }), + ) + }) + it('returns 400 for malformed JSON', async () => { const req = new Request('http://localhost/api/tasks/task-1', { method: 'PATCH', diff --git a/__tests__/components/products/auto-pr-toggle.test.tsx b/__tests__/components/products/auto-pr-toggle.test.tsx new file mode 100644 index 0000000..b2f2d0a --- /dev/null +++ b/__tests__/components/products/auto-pr-toggle.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' + +vi.mock('@/actions/products', () => ({ + updateAutoPrAction: vi.fn(), +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { updateAutoPrAction } from '@/actions/products' +import { AutoPrToggle } from '@/components/products/auto-pr-toggle' + +const mockAction = updateAutoPrAction as ReturnType + +beforeEach(() => { + vi.clearAllMocks() + mockAction.mockResolvedValue({ success: true }) +}) + +describe('AutoPrToggle', () => { + it('renders in off state with aria-checked=false', () => { + render() + const toggle = screen.getByRole('switch') + expect(toggle).toHaveAttribute('aria-checked', 'false') + }) + + it('renders in on state with aria-checked=true', () => { + render() + const toggle = screen.getByRole('switch') + expect(toggle).toHaveAttribute('aria-checked', 'true') + }) + + it('calls updateAutoPrAction with true when toggled on', async () => { + render() + fireEvent.click(screen.getByRole('switch')) + expect(mockAction).toHaveBeenCalledWith('prod-1', true) + }) + + it('calls updateAutoPrAction with false when toggled off', async () => { + render() + fireEvent.click(screen.getByRole('switch')) + expect(mockAction).toHaveBeenCalledWith('prod-1', false) + }) +}) diff --git a/__tests__/components/solo/task-detail-dialog.test.tsx b/__tests__/components/solo/task-detail-dialog.test.tsx new file mode 100644 index 0000000..0a6d34f --- /dev/null +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import type { SoloTask } from '@/components/solo/solo-board' + +// Mock heavy UI primitives to avoid portal/JSDOM issues +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; onOpenChange?: () => void; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => + r ? <>{r} : <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => {children}, +})) +vi.mock('@/components/ui/textarea', () => ({ + Textarea: (props: React.TextareaHTMLAttributes) =>