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:
Janpeter Visser 2026-05-01 13:42:18 +02:00 committed by GitHub
parent acb591266f
commit 9794a9baef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 725 additions and 20 deletions

View 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)
})
})

View file

@ -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',

View 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)
})
})

View 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')
})
})
})

View 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',
)
})
})

View file

@ -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',

View file

@ -250,3 +250,19 @@ export async function leaveProductAction(productId: string) {
revalidatePath('/settings')
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 }
}

View file

@ -7,6 +7,7 @@ import { ProductForm } from '@/components/products/product-form'
import { ArchiveProductButton } from '@/components/products/archive-product-button'
import { TeamManager } from '@/components/products/team-manager'
import { updateProductAction } from '@/actions/products'
import { AutoPrToggle } from '@/components/products/auto-pr-toggle'
import Link from 'next/link'
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>
<h2 className="text-sm font-medium text-foreground">Team</h2>

View file

@ -82,6 +82,7 @@ export default async function SoloProductPage({ params }: Props) {
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
@ -110,6 +111,7 @@ export default async function SoloProductPage({ params }: Props) {
unassignedStories={unassignedStories}
isDemo={session.isDemo ?? false}
currentUserId={session.userId}
repoUrl={product.repo_url}
/>
)
}

View 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() })
}

View file

@ -45,6 +45,9 @@ type JobPayload = {
product_id: string
status: string
branch?: string
pushed_at?: string
pr_url?: string
verify_result?: string
summary?: string
error?: string
}
@ -258,7 +261,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) {
],
},
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' },
})
@ -267,6 +270,9 @@ async function prisma_jobs_findActive(userId: string, productId: string) {
task_id: j.task_id,
status: jobStatusToApi(j.status),
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,
error: j.error ?? undefined,
}))

View file

@ -14,10 +14,15 @@ const patchSchema = z
.object({
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
implementation_plan: z.string().optional(),
verify_only: z.boolean().optional(),
})
.refine((data) => data.status !== undefined || data.implementation_plan !== undefined, {
message: 'Geef minimaal status of implementation_plan mee',
})
.refine(
(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(
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 planUpdate = parsed.data.implementation_plan !== undefined
const simpleUpdate = Object.keys(simpleData).length > 0
? await tx.task.update({
where: { id },
data: { implementation_plan: parsed.data.implementation_plan },
select: { id: true, status: true, implementation_plan: true },
data: simpleData,
select: { id: true, status: true, implementation_plan: true, verify_only: true },
})
: null
@ -98,12 +110,13 @@ export async function PATCH(
id: result.task.id,
status: result.task.status,
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')
})
@ -111,5 +124,6 @@ export async function PATCH(
id: updated.id,
status: taskStatusToApi(updated.status),
implementation_plan: updated.implementation_plan,
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
})
}

View 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>
)
}

View file

@ -25,6 +25,7 @@ export interface SoloTask {
priority: number
sort_order: number
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
verify_only: boolean
story_id: string
story_code: string | null
story_title: string
@ -38,6 +39,7 @@ export interface SoloBoardProps {
unassignedStories: UnassignedStory[]
isDemo: boolean
currentUserId: string
repoUrl?: string | null
}
const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE']
@ -48,7 +50,7 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus {
}
export function SoloBoard({
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl,
}: SoloBoardProps) {
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
@ -219,6 +221,7 @@ export function SoloBoard({
task={selectedTask}
productId={productId}
isDemo={isDemo}
repoUrl={repoUrl}
onClose={() => setSelectedTask(null)}
/>

View file

@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useSoloStore } from '@/stores/solo-store'
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
import { cn } from '@/lib/utils'
import { getBranchUrl } from '@/lib/job-status-url'
import type { SoloTask } from './solo-board'
const STATUS_COLORS: Record<string, string> = {
@ -33,6 +34,7 @@ interface TaskDetailDialogProps {
task: SoloTask | null
productId: string
isDemo: boolean
repoUrl?: string | null
onClose: () => void
}
@ -40,19 +42,29 @@ interface TaskDetailContentProps {
task: SoloTask
productId: string
isDemo: boolean
repoUrl?: string | null
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'
function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) {
const { updatePlan } = useSoloStore()
function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) {
const { updatePlan, updateVerifyOnly } = useSoloStore()
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only)
const [saveState, setSaveState] = useState<SaveState>('idle')
const [, startTransition] = useTransition()
const [jobPending, startJobTransition] = useTransition()
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
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 (
<>
<DialogHeader>
@ -159,6 +196,30 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
</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">
<Link
href={`/products/${productId}/sprint/planning`}
@ -206,8 +267,48 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
)}
{job?.status === 'done' && (
<span className="text-xs text-status-done">
Klaar{job.branch ? ` — branch ${job.branch}` : ''}
<span className="text-xs text-status-done flex items-center gap-2 flex-wrap">
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>
)}
@ -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 (
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-lg">
@ -229,6 +330,7 @@ export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetai
task={task}
productId={productId}
isDemo={isDemo}
repoUrl={repoUrl}
onClose={onClose}
/>
)}

View file

@ -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.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Before After
Before After

View file

@ -1138,12 +1138,26 @@ claude_jobs
claimed_by_token_id (FK → api_tokens, nullable)
claimed_at, started_at, finished_at
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([task_id, status])
@@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
```

4
lib/job-status-url.ts Normal file
View file

@ -0,0 +1,4 @@
export function getBranchUrl(repoUrl: string, branch: string): string {
const base = repoUrl.replace(/\.git$/, '').replace(/\/$/, '')
return `${base}/tree/${branch}`
}

View file

@ -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;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "claude_jobs" ADD COLUMN "retry_count" INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "products" ADD COLUMN "auto_pr" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "claude_jobs" ADD COLUMN "pr_url" TEXT;

View file

@ -38,6 +38,13 @@ enum ClaudeJobStatus {
CANCELLED
}
enum VerifyResult {
ALIGNED
PARTIAL
EMPTY
DIVERGENT
}
enum TaskStatus {
TO_DO
IN_PROGRESS
@ -124,6 +131,7 @@ model Product {
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -236,6 +244,7 @@ model Task {
priority Int
sort_order Float
status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[]
@ -261,10 +270,13 @@ model ClaudeJob {
started_at DateTime?
finished_at DateTime?
pushed_at DateTime?
verify_result VerifyResult?
plan_snapshot String?
branch String?
pr_url String?
summary String?
error String?
retry_count Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -4,18 +4,23 @@ import type { ClaudeJobStatusApi } from '@/lib/job-status'
type TaskStatus = SoloTask['status']
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
export interface JobState {
job_id: string
task_id: string
status: ClaudeJobStatusApi
branch?: string
pushed_at?: string | null
pr_url?: string | null
verify_result?: VerifyResultApi | null
summary?: string
error?: string
}
export type ClaudeJobEvent =
| { 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
// + 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
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string | null) => void
updateVerifyOnly: (taskId: string, value: boolean) => void
markPending: (taskId: string) => void
clearPending: (taskId: string) => void
@ -103,6 +109,9 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
updatePlan: (taskId, 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) =>
set((s) => {
if (s.pendingOps.has(taskId)) return s
@ -146,7 +155,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
return
}
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') {
set((s) => {
const next = { ...s.claudeJobsByTaskId }
@ -158,7 +167,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
set((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 },
},
}))
}

View file

@ -4,6 +4,10 @@
{
"path": "/api/cron/expire-questions",
"schedule": "0 4 * * *"
},
{
"path": "/api/cron/cleanup-agent-artifacts",
"schedule": "0 3 * * *"
}
]
}