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 () => {
|
it('returns 400 for malformed JSON', async () => {
|
||||||
const req = new Request('http://localhost/api/tasks/task-1', {
|
const req = new Request('http://localhost/api/tasks/task-1', {
|
||||||
method: 'PATCH',
|
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,
|
priority: 1,
|
||||||
sort_order: 1,
|
sort_order: 1,
|
||||||
status: 'TO_DO',
|
status: 'TO_DO',
|
||||||
|
verify_only: false,
|
||||||
story_id: 'story-1',
|
story_id: 'story-1',
|
||||||
story_code: 'ST-100',
|
story_code: 'ST-100',
|
||||||
story_title: 'Original Story',
|
story_title: 'Original Story',
|
||||||
|
|
|
||||||
|
|
@ -250,3 +250,19 @@ export async function leaveProductAction(productId: string) {
|
||||||
revalidatePath('/settings')
|
revalidatePath('/settings')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAutoPrAction(id: string, auto_pr: boolean) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = z.object({ auto_pr: z.boolean() }).safeParse({ auto_pr })
|
||||||
|
if (!parsed.success) return { error: 'Ongeldige waarde voor auto_pr' }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } })
|
||||||
|
if (!product) return { error: 'Product niet gevonden' }
|
||||||
|
|
||||||
|
await prisma.product.update({ where: { id }, data: { auto_pr: parsed.data.auto_pr } })
|
||||||
|
revalidatePath(`/products/${id}/settings`)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ProductForm } from '@/components/products/product-form'
|
||||||
import { ArchiveProductButton } from '@/components/products/archive-product-button'
|
import { ArchiveProductButton } from '@/components/products/archive-product-button'
|
||||||
import { TeamManager } from '@/components/products/team-manager'
|
import { TeamManager } from '@/components/products/team-manager'
|
||||||
import { updateProductAction } from '@/actions/products'
|
import { updateProductAction } from '@/actions/products'
|
||||||
|
import { AutoPrToggle } from '@/components/products/auto-pr-toggle'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -55,6 +56,16 @@ export default async function ProductSettingsPage({ params }: Props) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-border space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Agent-instellingen</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Automatiseer acties na een succesvolle agent-job.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AutoPrToggle productId={id} initialValue={product.auto_pr} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-border space-y-3">
|
<div className="mt-8 pt-6 border-t border-border space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-foreground">Team</h2>
|
<h2 className="text-sm font-medium text-foreground">Team</h2>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export default async function SoloProductPage({ params }: Props) {
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
sort_order: t.sort_order,
|
sort_order: t.sort_order,
|
||||||
status: t.status as SoloTask['status'],
|
status: t.status as SoloTask['status'],
|
||||||
|
verify_only: t.verify_only,
|
||||||
story_id: t.story.id,
|
story_id: t.story.id,
|
||||||
story_code: t.story.code,
|
story_code: t.story.code,
|
||||||
story_title: t.story.title,
|
story_title: t.story.title,
|
||||||
|
|
@ -110,6 +111,7 @@ export default async function SoloProductPage({ params }: Props) {
|
||||||
unassignedStories={unassignedStories}
|
unassignedStories={unassignedStories}
|
||||||
isDemo={session.isDemo ?? false}
|
isDemo={session.isDemo ?? false}
|
||||||
currentUserId={session.userId}
|
currentUserId={session.userId}
|
||||||
|
repoUrl={product.repo_url}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/api/cron/cleanup-agent-artifacts/route.ts
Normal file
24
app/api/cron/cleanup-agent-artifacts/route.ts
Normal file
|
|
@ -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() })
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,9 @@ type JobPayload = {
|
||||||
product_id: string
|
product_id: string
|
||||||
status: string
|
status: string
|
||||||
branch?: string
|
branch?: string
|
||||||
|
pushed_at?: string
|
||||||
|
pr_url?: string
|
||||||
|
verify_result?: string
|
||||||
summary?: string
|
summary?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +261,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true, task_id: true, status: true, branch: true, summary: true, error: true,
|
id: true, task_id: true, status: true, branch: true, pushed_at: true, pr_url: true, verify_result: true, summary: true, error: true,
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
})
|
})
|
||||||
|
|
@ -267,6 +270,9 @@ async function prisma_jobs_findActive(userId: string, productId: string) {
|
||||||
task_id: j.task_id,
|
task_id: j.task_id,
|
||||||
status: jobStatusToApi(j.status),
|
status: jobStatusToApi(j.status),
|
||||||
branch: j.branch ?? undefined,
|
branch: j.branch ?? undefined,
|
||||||
|
pushed_at: j.pushed_at?.toISOString() ?? undefined,
|
||||||
|
pr_url: j.pr_url ?? undefined,
|
||||||
|
verify_result: j.verify_result?.toLowerCase() as import('@/stores/solo-store').VerifyResultApi | undefined,
|
||||||
summary: j.summary ?? undefined,
|
summary: j.summary ?? undefined,
|
||||||
error: j.error ?? undefined,
|
error: j.error ?? undefined,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,15 @@ const patchSchema = z
|
||||||
.object({
|
.object({
|
||||||
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
|
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
|
||||||
implementation_plan: z.string().optional(),
|
implementation_plan: z.string().optional(),
|
||||||
|
verify_only: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.refine((data) => data.status !== undefined || data.implementation_plan !== undefined, {
|
.refine(
|
||||||
message: 'Geef minimaal status of implementation_plan mee',
|
(data) =>
|
||||||
})
|
data.status !== undefined ||
|
||||||
|
data.implementation_plan !== undefined ||
|
||||||
|
data.verify_only !== undefined,
|
||||||
|
{ message: 'Geef minimaal status, implementation_plan of verify_only mee' },
|
||||||
|
)
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -83,12 +88,19 @@ export async function PATCH(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Combine simple field writes (plan, verify_only) into one update call
|
||||||
|
const simpleData: { implementation_plan?: string; verify_only?: boolean } = {}
|
||||||
|
if (parsed.data.implementation_plan !== undefined)
|
||||||
|
simpleData.implementation_plan = parsed.data.implementation_plan
|
||||||
|
if (parsed.data.verify_only !== undefined)
|
||||||
|
simpleData.verify_only = parsed.data.verify_only
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const planUpdate = parsed.data.implementation_plan !== undefined
|
const simpleUpdate = Object.keys(simpleData).length > 0
|
||||||
? await tx.task.update({
|
? await tx.task.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { implementation_plan: parsed.data.implementation_plan },
|
data: simpleData,
|
||||||
select: { id: true, status: true, implementation_plan: true },
|
select: { id: true, status: true, implementation_plan: true, verify_only: true },
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
|
@ -98,12 +110,13 @@ export async function PATCH(
|
||||||
id: result.task.id,
|
id: result.task.id,
|
||||||
status: result.task.status,
|
status: result.task.status,
|
||||||
implementation_plan: result.task.implementation_plan,
|
implementation_plan: result.task.implementation_plan,
|
||||||
|
verify_only: simpleUpdate?.verify_only,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (planUpdate) return planUpdate
|
if (simpleUpdate) return simpleUpdate
|
||||||
|
|
||||||
// Should not reach here — patchSchema rejects bodies without status or implementation_plan.
|
// Should not reach here — patchSchema rejects bodies without recognized fields.
|
||||||
throw new Error('Geen wijzigingen')
|
throw new Error('Geen wijzigingen')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -111,5 +124,6 @@ export async function PATCH(
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
status: taskStatusToApi(updated.status),
|
status: taskStatusToApi(updated.status),
|
||||||
implementation_plan: updated.implementation_plan,
|
implementation_plan: updated.implementation_plan,
|
||||||
|
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
components/products/auto-pr-toggle.tsx
Normal file
55
components/products/auto-pr-toggle.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { updateAutoPrAction } from '@/actions/products'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface AutoPrToggleProps {
|
||||||
|
productId: string
|
||||||
|
initialValue: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoPrToggle({ productId, initialValue }: AutoPrToggleProps) {
|
||||||
|
const [enabled, setEnabled] = useState(initialValue)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
const newValue = !enabled
|
||||||
|
setEnabled(newValue)
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateAutoPrAction(productId, newValue)
|
||||||
|
if (result.error) {
|
||||||
|
setEnabled(!newValue)
|
||||||
|
toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||||
|
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
enabled ? 'bg-primary' : 'bg-input',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm',
|
||||||
|
'transition-transform duration-200',
|
||||||
|
enabled ? 'translate-x-4' : 'translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-foreground">Automatisch PR aanmaken na succesvolle agent-job</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export interface SoloTask {
|
||||||
priority: number
|
priority: number
|
||||||
sort_order: number
|
sort_order: number
|
||||||
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
|
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
|
||||||
|
verify_only: boolean
|
||||||
story_id: string
|
story_id: string
|
||||||
story_code: string | null
|
story_code: string | null
|
||||||
story_title: string
|
story_title: string
|
||||||
|
|
@ -38,6 +39,7 @@ export interface SoloBoardProps {
|
||||||
unassignedStories: UnassignedStory[]
|
unassignedStories: UnassignedStory[]
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
currentUserId: string
|
currentUserId: string
|
||||||
|
repoUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE']
|
const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE']
|
||||||
|
|
@ -48,7 +50,7 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SoloBoard({
|
export function SoloBoard({
|
||||||
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
|
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl,
|
||||||
}: SoloBoardProps) {
|
}: SoloBoardProps) {
|
||||||
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
|
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
|
||||||
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
|
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
|
||||||
|
|
@ -219,6 +221,7 @@ export function SoloBoard({
|
||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
productId={productId}
|
productId={productId}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
repoUrl={repoUrl}
|
||||||
onClose={() => setSelectedTask(null)}
|
onClose={() => setSelectedTask(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||||
import { useSoloStore } from '@/stores/solo-store'
|
import { useSoloStore } from '@/stores/solo-store'
|
||||||
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
|
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { getBranchUrl } from '@/lib/job-status-url'
|
||||||
import type { SoloTask } from './solo-board'
|
import type { SoloTask } from './solo-board'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
|
@ -33,6 +34,7 @@ interface TaskDetailDialogProps {
|
||||||
task: SoloTask | null
|
task: SoloTask | null
|
||||||
productId: string
|
productId: string
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
repoUrl?: string | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,19 +42,29 @@ interface TaskDetailContentProps {
|
||||||
task: SoloTask
|
task: SoloTask
|
||||||
productId: string
|
productId: string
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
repoUrl?: string | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VERIFY_RESULT_CONFIG: Record<string, { label: string; className: string }> = {
|
||||||
|
aligned: { label: 'Aligned', className: 'text-status-done' },
|
||||||
|
partial: { label: 'Gedeeltelijk', className: 'text-warning' },
|
||||||
|
divergent: { label: 'Divergent', className: 'text-error' },
|
||||||
|
empty: { label: 'Geen wijzigingen', className: 'text-muted-foreground' },
|
||||||
|
}
|
||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved'
|
type SaveState = 'idle' | 'saving' | 'saved'
|
||||||
|
|
||||||
function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) {
|
function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) {
|
||||||
const { updatePlan } = useSoloStore()
|
const { updatePlan, updateVerifyOnly } = useSoloStore()
|
||||||
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
|
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
|
||||||
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
|
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
|
||||||
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
|
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
|
||||||
|
const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only)
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const [jobPending, startJobTransition] = useTransition()
|
const [jobPending, startJobTransition] = useTransition()
|
||||||
|
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
|
||||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const savedPlanRef = useRef(task.implementation_plan ?? '')
|
const savedPlanRef = useRef(task.implementation_plan ?? '')
|
||||||
|
|
||||||
|
|
@ -108,6 +120,31 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVerifyOnlyToggle() {
|
||||||
|
if (isDemo) return
|
||||||
|
const newValue = !localVerifyOnly
|
||||||
|
setLocalVerifyOnly(newValue)
|
||||||
|
startVerifyOnlyTransition(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ verify_only: newValue }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setLocalVerifyOnly(!newValue)
|
||||||
|
toast.error('Verify-only bijwerken mislukt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateVerifyOnly(task.id, newValue)
|
||||||
|
} catch {
|
||||||
|
setLocalVerifyOnly(!newValue)
|
||||||
|
toast.error('Verify-only bijwerken mislukt')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -159,6 +196,30 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={localVerifyOnly}
|
||||||
|
onClick={handleVerifyOnlyToggle}
|
||||||
|
disabled={isDemo || verifyOnlyPending}
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border border-border flex items-center justify-center shrink-0',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
localVerifyOnly && 'bg-primary border-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{localVerifyOnly && (
|
||||||
|
<svg className="h-3 w-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DemoTooltip>
|
||||||
|
<span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
||||||
<Link
|
<Link
|
||||||
href={`/products/${productId}/sprint/planning`}
|
href={`/products/${productId}/sprint/planning`}
|
||||||
|
|
@ -206,8 +267,48 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job?.status === 'done' && (
|
{job?.status === 'done' && (
|
||||||
<span className="text-xs text-status-done">
|
<span className="text-xs text-status-done flex items-center gap-2 flex-wrap">
|
||||||
Klaar{job.branch ? ` — branch ${job.branch}` : ''}
|
Klaar{job.branch && !job.pushed_at ? ` — branch ${job.branch}` : ''}
|
||||||
|
{job.pr_url && (
|
||||||
|
<a
|
||||||
|
href={job.pr_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Open PR
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!job.pr_url && job.pushed_at && job.branch && repoUrl && (
|
||||||
|
<a
|
||||||
|
href={getBranchUrl(repoUrl, job.branch)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Open op GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{job.verify_result && (() => {
|
||||||
|
const cfg = VERIFY_RESULT_CONFIG[job.verify_result]
|
||||||
|
return cfg ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={
|
||||||
|
<span className={cn('font-medium cursor-default', cfg.className)}>
|
||||||
|
▸ {cfg.label}
|
||||||
|
</span>
|
||||||
|
} />
|
||||||
|
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||||
|
{job.verify_result === 'aligned' && 'De implementatie komt overeen met het plan.'}
|
||||||
|
{job.verify_result === 'partial' && 'De implementatie wijkt gedeeltelijk af van het plan.'}
|
||||||
|
{job.verify_result === 'divergent' && 'De implementatie wijkt significant af van het plan.'}
|
||||||
|
{job.verify_result === 'empty' && 'Er zijn geen codewijzigingen gedetecteerd.'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -219,7 +320,7 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetailDialogProps) {
|
export function TaskDetailDialog({ task, productId, isDemo, repoUrl, onClose }: TaskDetailDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
|
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
|
@ -229,6 +330,7 @@ export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetai
|
||||||
task={task}
|
task={task}
|
||||||
productId={productId}
|
productId={productId}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
repoUrl={repoUrl}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
26
docs/API.md
26
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
|
## Voorbeeldworkflow voor Claude Code
|
||||||
|
|
||||||
1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn.
|
1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 490 KiB After Width: | Height: | Size: 521 KiB |
|
|
@ -1138,12 +1138,26 @@ claude_jobs
|
||||||
claimed_by_token_id (FK → api_tokens, nullable)
|
claimed_by_token_id (FK → api_tokens, nullable)
|
||||||
claimed_at, started_at, finished_at
|
claimed_at, started_at, finished_at
|
||||||
plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim
|
plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim
|
||||||
branch, summary, error
|
branch, pushed_at, summary, error
|
||||||
|
verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT)
|
||||||
@@index([user_id, status])
|
@@index([user_id, status])
|
||||||
@@index([task_id, status])
|
@@index([task_id, status])
|
||||||
@@index([status, claimed_at]) — voor stale-claim cleanup
|
@@index([status, claimed_at]) — voor stale-claim cleanup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`:
|
||||||
|
|
||||||
|
| Waarde | Betekenis |
|
||||||
|
|---|---|
|
||||||
|
| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie |
|
||||||
|
| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker |
|
||||||
|
| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft |
|
||||||
|
| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig |
|
||||||
|
|
||||||
|
**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen.
|
||||||
|
|
||||||
|
**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte.
|
||||||
|
|
||||||
### NOTIFY/LISTEN flow
|
### NOTIFY/LISTEN flow
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
4
lib/job-status-url.ts
Normal file
4
lib/job-status-url.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function getBranchUrl(repoUrl: string, branch: string): string {
|
||||||
|
const base = repoUrl.replace(/\.git$/, '').replace(/\/$/, '')
|
||||||
|
return `${base}/tree/${branch}`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VerifyResult" AS ENUM ('ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "claude_jobs" ADD COLUMN "verify_result" "VerifyResult";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "verify_only" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "claude_jobs" ADD COLUMN "retry_count" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "products" ADD COLUMN "auto_pr" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "claude_jobs" ADD COLUMN "pr_url" TEXT;
|
||||||
|
|
@ -38,6 +38,13 @@ enum ClaudeJobStatus {
|
||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VerifyResult {
|
||||||
|
ALIGNED
|
||||||
|
PARTIAL
|
||||||
|
EMPTY
|
||||||
|
DIVERGENT
|
||||||
|
}
|
||||||
|
|
||||||
enum TaskStatus {
|
enum TaskStatus {
|
||||||
TO_DO
|
TO_DO
|
||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
|
|
@ -124,6 +131,7 @@ model Product {
|
||||||
description String?
|
description String?
|
||||||
repo_url String?
|
repo_url String?
|
||||||
definition_of_done String
|
definition_of_done String
|
||||||
|
auto_pr Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
@ -236,6 +244,7 @@ model Task {
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status TaskStatus @default(TO_DO)
|
status TaskStatus @default(TO_DO)
|
||||||
|
verify_only Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
@ -261,10 +270,13 @@ model ClaudeJob {
|
||||||
started_at DateTime?
|
started_at DateTime?
|
||||||
finished_at DateTime?
|
finished_at DateTime?
|
||||||
pushed_at DateTime?
|
pushed_at DateTime?
|
||||||
|
verify_result VerifyResult?
|
||||||
plan_snapshot String?
|
plan_snapshot String?
|
||||||
branch String?
|
branch String?
|
||||||
|
pr_url String?
|
||||||
summary String?
|
summary String?
|
||||||
error String?
|
error String?
|
||||||
|
retry_count Int @default(0)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,23 @@ import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||||
|
|
||||||
type TaskStatus = SoloTask['status']
|
type TaskStatus = SoloTask['status']
|
||||||
|
|
||||||
|
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
|
||||||
|
|
||||||
export interface JobState {
|
export interface JobState {
|
||||||
job_id: string
|
job_id: string
|
||||||
task_id: string
|
task_id: string
|
||||||
status: ClaudeJobStatusApi
|
status: ClaudeJobStatusApi
|
||||||
branch?: string
|
branch?: string
|
||||||
|
pushed_at?: string | null
|
||||||
|
pr_url?: string | null
|
||||||
|
verify_result?: VerifyResultApi | null
|
||||||
summary?: string
|
summary?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClaudeJobEvent =
|
export type ClaudeJobEvent =
|
||||||
| { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' }
|
| { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' }
|
||||||
| { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; summary?: string; error?: string }
|
| { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; summary?: string; error?: string }
|
||||||
|
|
||||||
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
|
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
|
||||||
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
|
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
|
||||||
|
|
@ -63,6 +68,7 @@ interface SoloStore {
|
||||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||||
updatePlan: (taskId: string, plan: string | null) => void
|
updatePlan: (taskId: string, plan: string | null) => void
|
||||||
|
updateVerifyOnly: (taskId: string, value: boolean) => void
|
||||||
|
|
||||||
markPending: (taskId: string) => void
|
markPending: (taskId: string) => void
|
||||||
clearPending: (taskId: string) => void
|
clearPending: (taskId: string) => void
|
||||||
|
|
@ -103,6 +109,9 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||||
updatePlan: (taskId, plan) =>
|
updatePlan: (taskId, plan) =>
|
||||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
|
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
|
||||||
|
|
||||||
|
updateVerifyOnly: (taskId, value) =>
|
||||||
|
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
|
||||||
|
|
||||||
markPending: (taskId) =>
|
markPending: (taskId) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (s.pendingOps.has(taskId)) return s
|
if (s.pendingOps.has(taskId)) return s
|
||||||
|
|
@ -146,7 +155,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === 'claude_job_status') {
|
if (event.type === 'claude_job_status') {
|
||||||
const { status, branch, summary, error } = event
|
const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event
|
||||||
if (status === 'cancelled') {
|
if (status === 'cancelled') {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = { ...s.claudeJobsByTaskId }
|
const next = { ...s.claudeJobsByTaskId }
|
||||||
|
|
@ -158,7 +167,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
claudeJobsByTaskId: {
|
claudeJobsByTaskId: {
|
||||||
...s.claudeJobsByTaskId,
|
...s.claudeJobsByTaskId,
|
||||||
[task_id]: { job_id, task_id, status, branch, summary, error },
|
[task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
{
|
{
|
||||||
"path": "/api/cron/expire-questions",
|
"path": "/api/cron/expire-questions",
|
||||||
"schedule": "0 4 * * *"
|
"schedule": "0 4 * * *"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/cron/cleanup-agent-artifacts",
|
||||||
|
"schedule": "0 3 * * *"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue