M13: Veilige Claude-agent-workflow (Scrum4Me-side) (#26)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat: add VerifyResult enum, verify_only on Task, verify_result on ClaudeJob Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add verify_result+pushed_at to JobState, VerifyResultApi type, SSE payload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: verify_only field on SoloTask, PATCH route saves verify_only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: TaskDetailDialog — verify_result display + verify_only checkbox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: verify_only PATCH + verify_result dialog render + store fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document VerifyResult enum, verify_only task field, pushed_at in architecture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
acb591266f
commit
9794a9baef
26 changed files with 725 additions and 20 deletions
63
__tests__/api/cron-cleanup-agent-artifacts.test.ts
Normal file
63
__tests__/api/cron-cleanup-agent-artifacts.test.ts
Normal file
|
|
@ -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<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const SECRET = 'test-cron-secret-abc123'
|
||||
|
||||
function makeReq(headers: Record<string, string> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
46
__tests__/components/products/auto-pr-toggle.test.tsx
Normal file
46
__tests__/components/products/auto-pr-toggle.test.tsx
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAction.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('AutoPrToggle', () => {
|
||||
it('renders in off state with aria-checked=false', () => {
|
||||
render(<AutoPrToggle productId="prod-1" initialValue={false} />)
|
||||
const toggle = screen.getByRole('switch')
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('renders in on state with aria-checked=true', () => {
|
||||
render(<AutoPrToggle productId="prod-1" initialValue={true} />)
|
||||
const toggle = screen.getByRole('switch')
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('calls updateAutoPrAction with true when toggled on', async () => {
|
||||
render(<AutoPrToggle productId="prod-1" initialValue={false} />)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(mockAction).toHaveBeenCalledWith('prod-1', true)
|
||||
})
|
||||
|
||||
it('calls updateAutoPrAction with false when toggled off', async () => {
|
||||
render(<AutoPrToggle productId="prod-1" initialValue={true} />)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(mockAction).toHaveBeenCalledWith('prod-1', false)
|
||||
})
|
||||
})
|
||||
228
__tests__/components/solo/task-detail-dialog.test.tsx
Normal file
228
__tests__/components/solo/task-detail-dialog.test.tsx
Normal file
|
|
@ -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 ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
}))
|
||||
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 }) => <span data-testid="tooltip-content">{children}</span>,
|
||||
}))
|
||||
vi.mock('@/components/ui/textarea', () => ({
|
||||
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||
}))
|
||||
vi.mock('@/components/ui/badge', () => ({
|
||||
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) =>
|
||||
<span className={className}>{children}</span>,
|
||||
}))
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick, disabled }: { children?: React.ReactNode; onClick?: () => void; disabled?: boolean }) =>
|
||||
<button onClick={onClick} disabled={disabled}>{children}</button>,
|
||||
}))
|
||||
vi.mock('@/components/markdown', () => ({
|
||||
Markdown: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
vi.mock('@/components/shared/demo-tooltip', () => ({
|
||||
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('@/actions/claude-jobs', () => ({
|
||||
enqueueClaudeJobAction: vi.fn(),
|
||||
cancelClaudeJobAction: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/job-status-url', () => ({
|
||||
getBranchUrl: (repoUrl: string, branch: string) => `${repoUrl}/tree/${branch}`,
|
||||
}))
|
||||
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import { TaskDetailDialog } from '@/components/solo/task-detail-dialog'
|
||||
|
||||
const baseTask: SoloTask = {
|
||||
id: 'task-1',
|
||||
title: 'Test taak',
|
||||
description: null,
|
||||
implementation_plan: null,
|
||||
priority: 2,
|
||||
sort_order: 1,
|
||||
status: 'TO_DO',
|
||||
verify_only: false,
|
||||
story_id: 'story-1',
|
||||
story_code: 'ST-100',
|
||||
story_title: 'Test Story',
|
||||
task_code: 'ST-100.1',
|
||||
}
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
productId: 'prod-1',
|
||||
isDemo: false,
|
||||
repoUrl: 'https://github.com/user/repo',
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
function jobDone(verify_result?: string) {
|
||||
return {
|
||||
'task-1': {
|
||||
job_id: 'j1',
|
||||
task_id: 'task-1',
|
||||
status: 'done' as const,
|
||||
branch: 'feat/job-abc',
|
||||
pushed_at: '2026-01-01T00:00:00Z',
|
||||
...(verify_result && { verify_result: verify_result as import('@/stores/solo-store').VerifyResultApi }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('TaskDetailDialog — verify_result display', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSoloStore.setState({
|
||||
tasks: { 'task-1': baseTask },
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
|
||||
it('shows no verify label when job has no verify_result', () => {
|
||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone() })
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
expect(screen.queryByText(/Aligned|Gedeeltelijk|Divergent|Geen wijzigingen/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Aligned label with text-status-done class for verify_result=aligned', () => {
|
||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('aligned') })
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
const label = screen.getByText(/Aligned/)
|
||||
expect(label).toHaveClass('text-status-done')
|
||||
})
|
||||
|
||||
it('shows Gedeeltelijk label with text-warning class for verify_result=partial', () => {
|
||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('partial') })
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
const label = screen.getByText(/Gedeeltelijk/)
|
||||
expect(label).toHaveClass('text-warning')
|
||||
})
|
||||
|
||||
it('shows Divergent label with text-error class for verify_result=divergent', () => {
|
||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('divergent') })
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
const label = screen.getByText(/Divergent/)
|
||||
expect(label).toHaveClass('text-error')
|
||||
})
|
||||
|
||||
it('shows Geen wijzigingen label with text-muted-foreground class for verify_result=empty', () => {
|
||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('empty') })
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
const label = screen.getByText(/Geen wijzigingen/)
|
||||
expect(label).toHaveClass('text-muted-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskDetailDialog — PR link display', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSoloStore.setState({
|
||||
tasks: { 'task-1': baseTask },
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
|
||||
it('shows "Open PR" link when pr_url is set', () => {
|
||||
useSoloStore.setState({
|
||||
claudeJobsByTaskId: {
|
||||
'task-1': {
|
||||
job_id: 'j1',
|
||||
task_id: 'task-1',
|
||||
status: 'done',
|
||||
branch: 'feat/job-abc',
|
||||
pushed_at: '2026-01-01T00:00:00Z',
|
||||
pr_url: 'https://github.com/org/repo/pull/42',
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
const link = screen.getByRole('link', { name: /Open PR/i })
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42')
|
||||
})
|
||||
|
||||
it('shows "Open op GitHub" branch link when pushed_at is set but no pr_url', () => {
|
||||
useSoloStore.setState({
|
||||
claudeJobsByTaskId: {
|
||||
'task-1': {
|
||||
job_id: 'j1',
|
||||
task_id: 'task-1',
|
||||
status: 'done',
|
||||
branch: 'feat/job-abc',
|
||||
pushed_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
expect(screen.queryByText(/Open PR/)).toBeNull()
|
||||
const link = screen.getByRole('link', { name: /Open op GitHub/i })
|
||||
expect(link).toHaveAttribute('href', expect.stringContaining('feat/job-abc'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskDetailDialog — verify_only checkbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSoloStore.setState({
|
||||
tasks: { 'task-1': baseTask },
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
|
||||
it('renders verify_only checkbox unchecked when task.verify_only is false', () => {
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('renders verify_only checkbox checked when task.verify_only is true', () => {
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: true }} />)
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('calls PATCH with verify_only toggled on click', async () => {
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/tasks/task-1',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ verify_only: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('reverts optimistic toggle when PATCH fails', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false })
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
})
|
||||
22
__tests__/lib/job-status-url.test.ts
Normal file
22
__tests__/lib/job-status-url.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { getBranchUrl } from '@/lib/job-status-url'
|
||||
|
||||
describe('getBranchUrl', () => {
|
||||
it('builds a GitHub tree URL from repo URL and branch', () => {
|
||||
expect(getBranchUrl('https://github.com/owner/repo', 'feat/job-abc12345')).toBe(
|
||||
'https://github.com/owner/repo/tree/feat/job-abc12345',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips trailing .git suffix', () => {
|
||||
expect(getBranchUrl('https://github.com/owner/repo.git', 'feat/job-abc')).toBe(
|
||||
'https://github.com/owner/repo/tree/feat/job-abc',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips trailing slash', () => {
|
||||
expect(getBranchUrl('https://github.com/owner/repo/', 'feat/job-abc')).toBe(
|
||||
'https://github.com/owner/repo/tree/feat/job-abc',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@ const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
|
|||
priority: 1,
|
||||
sort_order: 1,
|
||||
status: 'TO_DO',
|
||||
verify_only: false,
|
||||
story_id: 'story-1',
|
||||
story_code: 'ST-100',
|
||||
story_title: 'Original Story',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue