M13: Claude job queue — 'Voer uit'-knop + worker presence (ST-1111) (#18)

* feat(ST-1111.1): add ClaudeJob model and state-machine enum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.2): add ClaudeJob status API mappers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.7): add job status pill with spinner on solo task cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ST-1111.8): cover job-status mappers and enqueue/cancel actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-1111.9): document Claude job queue architecture and agent flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10a): add ClaudeWorker presence model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10c): forward worker presence events on solo SSE + initial count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-29 19:51:48 +02:00 committed by GitHub
parent 1cb5772edd
commit 73087e9705
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 921 additions and 27 deletions

View file

@ -262,7 +262,7 @@ docs(ST-XXX): document profile feature
Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) die de REST-API als native tools voor Claude Code aanbiedt. Schema's worden gedeeld via een git submodule (`vendor/scrum4me`), niet gedupliceerd.
### Tools beschikbaar in Claude Code (16)
### Tools beschikbaar in Claude Code (18)
**Read / context:**
- `mcp__scrum4me__health` — service + DB ping
@ -285,6 +285,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
- `mcp__scrum4me__list_open_questions` — eigen vragen, max 50, recente eerst
- `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag
**Job queue — agent worker mode (M13):**
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED.
- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`.
### Prompt
- `implement_next_story` (arg: `product_id`) — end-to-end workflow

View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstTask,
mockFindFirstJob,
mockCreateJob,
mockUpdateJob,
mockExecuteRaw,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstTask: vi.fn(),
mockFindFirstJob: vi.fn(),
mockCreateJob: vi.fn(),
mockUpdateJob: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: { findFirst: mockFindFirstTask },
claudeJob: {
findFirst: mockFindFirstJob,
create: mockCreateJob,
update: mockUpdateJob,
},
$executeRaw: mockExecuteRaw,
},
}))
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const TASK_ID = 'task-cuid-1'
const JOB_ID = 'job-cuid-1'
const PRODUCT_ID = 'product-cuid-1'
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
})
describe('enqueueClaudeJobAction', () => {
it('happy path: creates job with QUEUED status', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue(null)
mockCreateJob.mockResolvedValue({ id: JOB_ID })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toEqual({ success: true, jobId: JOB_ID })
expect(mockCreateJob).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
)
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('returns error when task not found', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(null)
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Task niet gevonden' })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('allows new enqueue after terminal (DONE) job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue(null) // no active job
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
})
})
describe('cancelClaudeJobAction', () => {
it('happy path: cancels QUEUED job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
mockUpdateJob.mockResolvedValue({})
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toEqual({ success: true })
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: JOB_ID },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
)
})
it('demo user is blocked', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('returns error when job not found (ownership check)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(null)
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Job niet gevonden' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('returns error when cancelling terminal (DONE) job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('returns error when cancelling FAILED job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
})
})

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import {
jobStatusToApi,
jobStatusFromApi,
JOB_STATUS_API_VALUES,
ACTIVE_JOB_STATUSES,
} from '@/lib/job-status'
describe('job-status mappers', () => {
it('round-trips every API value', () => {
for (const api of JOB_STATUS_API_VALUES) {
const db = jobStatusFromApi(api)
expect(db).not.toBeNull()
expect(jobStatusToApi(db!)).toBe(api)
}
})
it('returns null for invalid input', () => {
expect(jobStatusFromApi('NOT_A_STATUS')).toBeNull()
expect(jobStatusFromApi('')).toBeNull()
expect(jobStatusFromApi('active')).toBeNull()
})
it('is case-insensitive on the API side (accepts both upper and lower)', () => {
expect(jobStatusFromApi('running')).toBe('RUNNING')
expect(jobStatusFromApi('RUNNING')).toBe('RUNNING')
expect(jobStatusFromApi('QUEUED')).toBe('QUEUED')
})
it('maps all 6 DB statuses to API', () => {
expect(jobStatusToApi('QUEUED')).toBe('queued')
expect(jobStatusToApi('CLAIMED')).toBe('claimed')
expect(jobStatusToApi('RUNNING')).toBe('running')
expect(jobStatusToApi('DONE')).toBe('done')
expect(jobStatusToApi('FAILED')).toBe('failed')
expect(jobStatusToApi('CANCELLED')).toBe('cancelled')
})
it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => {
expect(ACTIVE_JOB_STATUSES).toEqual(expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']))
expect(ACTIVE_JOB_STATUSES).toHaveLength(3)
})
})

97
actions/claude-jobs.ts Normal file
View file

@ -0,0 +1,97 @@
'use server'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import { productAccessFilter } from '@/lib/product-access'
import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status'
type EnqueueResult =
| { success: true; jobId: string }
| { error: string; jobId?: string }
type CancelResult = { success: true } | { error: string }
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!taskId) return { error: 'task_id is verplicht' }
// Resolve task + product access in one query
const task = await prisma.task.findFirst({
where: {
id: taskId,
story: { product: productAccessFilter(session.userId) },
},
select: { id: true, story: { select: { product_id: true } } },
})
if (!task) return { error: 'Task niet gevonden' }
const productId = task.story.product_id
// Idempotency: weiger als er al een actieve job voor deze task bestaat
const existing = await prisma.claudeJob.findFirst({
where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } },
select: { id: true },
})
if (existing) {
return { error: 'Er loopt al een agent voor deze task', jobId: existing.id }
}
const job = await prisma.claudeJob.create({
data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
})
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
type: 'claude_job_enqueued',
job_id: job.id,
task_id: taskId,
user_id: session.userId,
product_id: productId,
status: 'queued',
})}::text)
`
revalidatePath(`/products/${productId}/solo`)
return { success: true, jobId: job.id }
}
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!jobId) return { error: 'job_id is verplicht' }
const job = await prisma.claudeJob.findFirst({
where: { id: jobId, user_id: session.userId },
select: { id: true, status: true, task_id: true, product_id: true },
})
if (!job) return { error: 'Job niet gevonden' }
if (!ACTIVE_JOB_STATUSES.includes(job.status)) {
return { error: 'Alleen actieve jobs kunnen geannuleerd worden' }
}
await prisma.claudeJob.update({
where: { id: jobId },
data: { status: 'CANCELLED', finished_at: new Date() },
})
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
type: 'claude_job_status',
job_id: jobId,
task_id: job.task_id,
user_id: session.userId,
product_id: job.product_id,
status: jobStatusToApi('CANCELLED'),
})}::text)
`
revalidatePath(`/products/${job.product_id}/solo`)
return { success: true }
}

View file

@ -23,7 +23,7 @@ const CHANNEL = 'scrum4me_changes'
const HEARTBEAT_MS = 25_000
const HARD_CLOSE_MS = 240_000
interface NotifyPayload {
type EntityPayload = {
op: 'I' | 'U' | 'D'
// M11 (ST-1101) voegt entity:'question' toe op hetzelfde scrum4me_changes-
// kanaal; we filteren die hieronder weg zodat solo-clients geen
@ -37,12 +37,49 @@ interface NotifyPayload {
changed_fields?: string[]
}
type JobPayload = {
type: 'claude_job_enqueued' | 'claude_job_status'
job_id: string
task_id: string
user_id: string
product_id: string
status: string
branch?: string
summary?: string
error?: string
}
type WorkerPayload = {
type: 'worker_connected' | 'worker_disconnected'
user_id: string
token_id: string
product_id?: string
}
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
function isJobPayload(p: NotifyPayload): p is JobPayload {
return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status')
}
function isWorkerPayload(p: NotifyPayload): p is WorkerPayload {
return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected')
}
function shouldEmit(
payload: NotifyPayload,
productId: string,
activeSprintId: string | null,
userId: string,
): boolean {
if (isJobPayload(payload)) {
return payload.user_id === userId && payload.product_id === productId
}
if (isWorkerPayload(payload)) {
return payload.user_id === userId
}
// M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier.
if (payload.entity === 'question') return false
@ -159,6 +196,17 @@ export async function GET(request: NextRequest) {
})}\n\n`,
)
// Stuur initiële ClaudeJob-state zodat de UI synchroon is bij reconnect
const activeJobs = await prisma_jobs_findActive(userId, productId)
if (activeJobs.length > 0) {
enqueue(`event: claude_jobs_initial\ndata: ${JSON.stringify(activeJobs)}\n\n`)
}
// Stale workers opruimen + actieve count sturen
await prisma_workers_cleanup()
const workerCount = await prisma_workers_count(userId)
enqueue(`event: workers_initial\ndata: ${JSON.stringify({ count: workerCount })}\n\n`)
// Heartbeat als SSE-comment — voorkomt proxy-timeouts
heartbeatTimer = setInterval(() => {
enqueue(`: heartbeat\n\n`)
@ -186,8 +234,6 @@ export async function GET(request: NextRequest) {
})
}
// Lokaal helper — Prisma vermijden voor deze ene query om de pg-only flow
// schoon te houden. Geeft de actieve sprint van een product, of null.
async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> {
const { prisma } = await import('@/lib/prisma')
return prisma.sprint.findFirst({
@ -196,3 +242,51 @@ async function prisma_sprint_findActive(productId: string): Promise<{ id: string
orderBy: { created_at: 'desc' },
})
}
async function prisma_jobs_findActive(userId: string, productId: string) {
const { prisma } = await import('@/lib/prisma')
const { jobStatusToApi } = await import('@/lib/job-status')
const today = new Date()
today.setHours(0, 0, 0, 0)
const jobs = await prisma.claudeJob.findMany({
where: {
user_id: userId,
product_id: productId,
OR: [
{ status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] } },
{ status: { in: ['DONE', 'FAILED'] }, finished_at: { gte: today } },
],
},
select: {
id: true, task_id: true, status: true, branch: true, summary: true, error: true,
},
orderBy: { created_at: 'asc' },
})
return jobs.map(j => ({
job_id: j.id,
task_id: j.task_id,
status: jobStatusToApi(j.status),
branch: j.branch ?? undefined,
summary: j.summary ?? undefined,
error: j.error ?? undefined,
}))
}
const WORKER_STALE_MS = 60_000
async function prisma_workers_cleanup() {
const { prisma } = await import('@/lib/prisma')
await prisma.claudeWorker.deleteMany({
where: { last_seen_at: { lt: new Date(Date.now() - WORKER_STALE_MS) } },
})
}
async function prisma_workers_count(userId: string): Promise<number> {
const { prisma } = await import('@/lib/prisma')
return prisma.claudeWorker.count({
where: {
user_id: userId,
last_seen_at: { gt: new Date(Date.now() - 15_000) },
},
})
}

View file

@ -0,0 +1,21 @@
import type { ClaudeJobStatusApi } from '@/lib/job-status'
export const JOB_STATUS_LABELS: Record<ClaudeJobStatusApi, string> = {
queued: 'Wacht…',
claimed: 'Geclaimd…',
running: 'Bezig…',
done: 'Klaar',
failed: 'Mislukt',
cancelled: 'Geannuleerd',
}
export const JOB_STATUS_COLORS: Record<ClaudeJobStatusApi, string> = {
queued: 'bg-status-todo/15 text-status-todo border-status-todo/30',
claimed: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
running: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
done: 'bg-status-done/15 text-status-done border-status-done/30',
failed: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30',
cancelled: 'bg-muted text-muted-foreground border-border',
}
export const JOB_STATUS_ACTIVE = new Set<ClaudeJobStatusApi>(['queued', 'claimed', 'running'])

View file

@ -92,6 +92,7 @@ export function SoloBoard({
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
@ -192,6 +193,13 @@ export function SoloBoard({
status={realtimeStatus}
showConnectingIndicator={showConnectingIndicator}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-1">
<span className={cn(
'size-2 rounded-full',
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
)} />
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
</div>
</div>
{sprintGoal && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{sprintGoal}</p>

View file

@ -3,8 +3,11 @@
import type React from 'react'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { CodeBadge } from '@/components/shared/code-badge'
import { JOB_STATUS_LABELS, JOB_STATUS_COLORS, JOB_STATUS_ACTIVE } from '@/components/shared/job-status'
import { useSoloStore } from '@/stores/solo-store'
import type { SoloTask } from './solo-board'
const PRIORITY_BORDER: Record<number, string> = {
@ -21,6 +24,7 @@ interface SoloTaskCardProps {
}
export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
disabled: isDemo,
@ -51,10 +55,26 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
<p className="text-sm text-foreground leading-snug flex-1">{task.title}</p>
{task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
<div className="flex items-center justify-between gap-2 mt-0.5">
<p className="text-xs text-muted-foreground truncate">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
{job && (
<span
className={cn(
'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0',
JOB_STATUS_COLORS[job.status],
)}
onClick={(e) => { e.stopPropagation(); onClick() }}
role="button"
aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`}
>
{JOB_STATUS_ACTIVE.has(job.status) && <Loader2 className="animate-spin" size={8} />}
{JOB_STATUS_LABELS[job.status]}
</span>
)}
</div>
</div>
)
}

View file

@ -5,9 +5,12 @@ import Link from 'next/link'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSoloStore } from '@/stores/solo-store'
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
import { cn } from '@/lib/utils'
import type { SoloTask } from './solo-board'
@ -43,12 +46,34 @@ type SaveState = 'idle' | 'saving' | 'saved'
function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) {
const { updatePlan } = useSoloStore()
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
const [saveState, setSaveState] = useState<SaveState>('idle')
const [, startTransition] = useTransition()
const [jobPending, startJobTransition] = useTransition()
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const savedPlanRef = useRef(task.implementation_plan ?? '')
function handleEnqueue() {
startJobTransition(async () => {
const result = await enqueueClaudeJobAction(task.id)
if ('error' in result) {
toast.error(result.error)
} else {
toast.success('Agent ingeschakeld')
}
})
}
function handleCancel() {
if (!job) return
startJobTransition(async () => {
const result = await cancelClaudeJobAction(job.job_id)
if ('error' in result) toast.error(result.error)
})
}
function handleBlur() {
if (isDemo || localPlan === savedPlanRef.current) return
@ -133,14 +158,61 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
</div>
</div>
<div className="-mx-4 -mb-4 flex items-center 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
href={`/products/${productId}/sprint/planning`}
className="text-xs text-primary hover:underline"
className="text-xs text-primary hover:underline mr-auto"
onClick={onClose}
>
Open in Sprint Board
</Link>
{!isDemo && !job && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
className="h-7 text-xs"
onClick={handleEnqueue}
disabled={jobPending || connectedWorkers === 0}
>
Voer uit
</Button>
}
/>
{connectedWorkers === 0 && (
<TooltipContent side="top" className="max-w-xs text-xs">
Geen Claude Code-sessie verbonden. Start claude lokaal en zeg &apos;wacht op jobs&apos;.
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{job?.status === 'queued' && (
<span className="text-xs text-muted-foreground">Wacht op agent</span>
)}
{(job?.status === 'claimed' || job?.status === 'running') && (
<>
<span className="text-xs text-muted-foreground">Bezig: {job.summary ?? '…'}</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleCancel} disabled={jobPending}>
Annuleer
</Button>
</>
)}
{job?.status === 'done' && (
<span className="text-xs text-status-done">
Klaar{job.branch ? ` — branch ${job.branch}` : ''}
</span>
)}
{job?.status === 'failed' && (
<span className="text-xs text-error">Mislukt: {job.error}</span>
)}
</div>
</>
)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 488 KiB

Before After
Before After

View file

@ -0,0 +1,69 @@
# ST-1111 — 'Voer uit'-knop met Claude Code job queue
**Story:** Als developer wil ik op het solo-scherm per task een 'Voer uit'-knop, zodat ik mijn lokale Claude Code-sessie kan inschakelen om de taak uit te voeren.
**Branch:** `feat/M13-claude-job-queue`
---
## Sub-tasks en commits
| Task | Commit |
|---|---|
| ST-1111.1 DB: ClaudeJob model + enum + migration | `5274e1e` |
| ST-1111.2 API: ClaudeJob status mappers | `a1b1f69` |
| ST-1111.3 Server actions: enqueue + cancel | `9d9fb4b` |
| ST-1111.4 SSE: ClaudeJob events op solo-stream + initial state | `ece0aa9` |
| ST-1111.5 MCP-tools (scrum4me-mcp repo — aparte PR) | — |
| ST-1111.6 UI: 'Voer uit' + cancel in TaskDetailDialog | `b9c65eb` |
| ST-1111.7 UI: status-pill op SoloTaskCard | `dace427` |
| ST-1111.8 Tests: mappers + actions | `2c2a246` |
| ST-1111.9 Docs | dit bestand |
---
## Architectuur
### State machine
```
QUEUED → CLAIMED → RUNNING → DONE
→ FAILED
→ CANCELLED (cancel-knop of server action)
CLAIMED → QUEUED (stale cleanup, >30min, via wait_for_job)
```
### NOTIFY-pijplijn
Omdat `claude_jobs` geen row-trigger heeft (zoals `tasks` en `stories`), stuurt de **server action** zelf `pg_notify` via `prisma.$executeRaw`:
```ts
await prisma.$executeRaw`SELECT pg_notify('scrum4me_changes', ${JSON.stringify(payload)}::text)`
```
Voordeel: expliciete controle over het payload-shape (met `type` i.p.v. `entity`). Nadeel: MCP-tools in de `scrum4me-mcp`-repo moeten hun eigen NOTIFY-aanroep hebben bij `update_job_status`.
### SSE-routing
De bestaande `/api/realtime/solo`-route herkent nu twee payload-shapes:
- `entity: 'task'|'story'` — bestaande trigger-events
- `type: 'claude_job_enqueued'|'claude_job_status'` — nieuwe job-events
Job-events worden gefilterd op `user_id + product_id`. Bij connect stuurt de route een `claude_jobs_initial`-event met alle actieve + recente (vandaag) jobs.
### Idempotency
`enqueueClaudeJobAction` weigert als `claude_jobs WHERE task_id=X AND status IN (QUEUED, CLAIMED, RUNNING)` bestaat. De client ontvangt `{ error, jobId }` zodat de UI naar de actieve job kan linken in plaats van een nieuw venstertje te openen.
---
## Beslissingen
**Waarom geen DB-trigger voor NOTIFY?**
De MCP-server claimt jobs via raw SQL (FOR UPDATE SKIP LOCKED); die schrijft ook direct naar de DB. Een trigger zou clean zijn, maar de MCP-tools moeten hoe dan ook hun eigen NOTIFY-payload bouwen voor `update_job_status`. Applicatie-NOTIFY houdt de payloads consistent en expliciet.
**Waarom `cancelled` verwijderd uit de store?**
Geannuleerde jobs zijn terminaal; het pill-element zou "Geannuleerd" tonen tot de gebruiker een refresh doet. In plaats daarvan wist `handleJobEvent` de entry bij `status === 'cancelled'` zodat de kaart teruggaat naar de "Voer uit"-staat.
**Auto-clear DONE/FAILED?**
Niet geïmplementeerd in v1. De pill blijft staan totdat de SSE-connectie herstart (refresh, tab-hidden+visible). Acceptabel voor de eerste iteratie.

View file

@ -1047,6 +1047,56 @@ Patroon:
**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && <span {...listeners} />}`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren.
---
## Claude job queue (M13 — ST-1111)
Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status.
### State machine
```
QUEUED → CLAIMED → RUNNING → DONE
→ FAILED
→ CANCELLED (door user)
CLAIMED → QUEUED (stale claim cleanup, >30min)
```
### ClaudeJob model
```
claude_jobs
id, user_id, product_id, task_id
status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED)
claimed_by_token_id (FK → api_tokens, nullable)
claimed_at, started_at, finished_at
branch, summary, error
@@index([user_id, status])
@@index([task_id, status])
@@index([status, claimed_at]) — voor stale-claim cleanup
```
### NOTIFY/LISTEN flow
```
UI klikt 'Voer uit'
→ enqueueClaudeJobAction() Server Action
→ prisma.claudeJob.create(QUEUED)
→ prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...})
→ /api/realtime/solo SSE server-side filter: user_id + product_id
→ EventSource.onmessage browser: handleJobEvent()
→ useSoloStore.claudeJobsByTaskId map
→ SoloTaskCard pill + dialog-footer update
```
### Idempotency
`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken.
### Hybride-ready
De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd.
## Environment variables
| Variabele | Doel | Waar te vinden |

32
lib/job-status.ts Normal file
View file

@ -0,0 +1,32 @@
import type { ClaudeJobStatus } from '@prisma/client'
const JOB_DB_TO_API = {
QUEUED: 'queued',
CLAIMED: 'claimed',
RUNNING: 'running',
DONE: 'done',
FAILED: 'failed',
CANCELLED: 'cancelled',
} as const satisfies Record<ClaudeJobStatus, string>
const JOB_API_TO_DB: Record<string, ClaudeJobStatus> = {
queued: 'QUEUED',
claimed: 'CLAIMED',
running: 'RUNNING',
done: 'DONE',
failed: 'FAILED',
cancelled: 'CANCELLED',
}
export type ClaudeJobStatusApi = typeof JOB_DB_TO_API[ClaudeJobStatus]
export function jobStatusToApi(s: ClaudeJobStatus): ClaudeJobStatusApi {
return JOB_DB_TO_API[s]
}
export function jobStatusFromApi(s: string): ClaudeJobStatus | null {
return JOB_API_TO_DB[s.toLowerCase()] ?? null
}
export const JOB_STATUS_API_VALUES = Object.values(JOB_DB_TO_API)
export const ACTIVE_JOB_STATUSES: ClaudeJobStatus[] = ['QUEUED', 'CLAIMED', 'RUNNING']

View file

@ -20,7 +20,7 @@
import { useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
import { useSoloStore } from '@/stores/solo-store'
import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
const BACKOFF_START_MS = 1_000
const BACKOFF_MAX_MS = 30_000
@ -35,6 +35,11 @@ export function useSoloRealtime(productId: string | null) {
useEffect(() => {
const setStatus = useSoloStore.getState().setRealtimeStatus
const handleEvent = useSoloStore.getState().handleRealtimeEvent
const handleJobEvent = useSoloStore.getState().handleJobEvent
const initJobs = useSoloStore.getState().initJobs
const setWorkers = useSoloStore.getState().setWorkers
const incrementWorkers = useSoloStore.getState().incrementWorkers
const decrementWorkers = useSoloStore.getState().decrementWorkers
if (!productId) {
// Geen actief product (gebruiker zit niet op /solo) — stream uit
@ -84,10 +89,39 @@ export function useSoloRealtime(productId: string | null) {
scheduleIndicator('open')
})
source.addEventListener('claude_jobs_initial', (e) => {
if (!e.data) return
try {
initJobs(JSON.parse(e.data) as JobState[])
} catch {
// ignore malformed payload
}
})
source.addEventListener('workers_initial', (e) => {
if (!e.data) return
try {
const { count } = JSON.parse(e.data) as { count: number }
setWorkers(count)
} catch {
// ignore malformed payload
}
})
source.onmessage = (e) => {
if (!e.data) return
try {
const payload = JSON.parse(e.data) as RealtimeEvent
const raw = JSON.parse(e.data) as RealtimeEvent | ClaudeJobEvent | { type: string }
if ('type' in raw) {
if (raw.type === 'claude_job_enqueued' || raw.type === 'claude_job_status') {
handleJobEvent(raw as ClaudeJobEvent)
return
}
if (raw.type === 'worker_connected') { incrementWorkers(); return }
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
return
}
const payload = raw as RealtimeEvent
// Animatie A: kanban-move animeren via View Transitions API. Voor
// task UPDATE-events wrap'en we de store-update in een view
// transition. flushSync forceert React om synchroon te renderen

View file

@ -0,0 +1,43 @@
-- CreateEnum
CREATE TYPE "ClaudeJobStatus" AS ENUM ('QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED');
-- CreateTable
CREATE TABLE "claude_jobs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"task_id" TEXT NOT NULL,
"status" "ClaudeJobStatus" NOT NULL DEFAULT 'QUEUED',
"claimed_by_token_id" TEXT,
"claimed_at" TIMESTAMP(3),
"started_at" TIMESTAMP(3),
"finished_at" TIMESTAMP(3),
"branch" TEXT,
"summary" TEXT,
"error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "claude_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "claude_jobs_user_id_status_idx" ON "claude_jobs"("user_id", "status");
-- CreateIndex
CREATE INDEX "claude_jobs_task_id_status_idx" ON "claude_jobs"("task_id", "status");
-- CreateIndex
CREATE INDEX "claude_jobs_status_claimed_at_idx" ON "claude_jobs"("status", "claimed_at");
-- AddForeignKey
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_claimed_by_token_id_fkey" FOREIGN KEY ("claimed_by_token_id") REFERENCES "api_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "claude_workers" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_id" TEXT NOT NULL,
"product_id" TEXT,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "claude_workers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "claude_workers_user_id_last_seen_at_idx" ON "claude_workers"("user_id", "last_seen_at");
-- CreateIndex
CREATE UNIQUE INDEX "claude_workers_token_id_key" ON "claude_workers"("token_id");
-- AddForeignKey
ALTER TABLE "claude_workers" ADD CONSTRAINT "claude_workers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "claude_workers" ADD CONSTRAINT "claude_workers_token_id_fkey" FOREIGN KEY ("token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -29,6 +29,15 @@ enum PbiStatus {
DONE
}
enum ClaudeJobStatus {
QUEUED
CLAIMED
RUNNING
DONE
FAILED
CANCELLED
}
enum TaskStatus {
TO_DO
IN_PROGRESS
@ -66,14 +75,16 @@ model User {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
api_tokens ApiToken[]
products Product[]
todos Todo[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
api_tokens ApiToken[]
products Product[]
todos Todo[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
@@index([active_product_id])
@@map("users")
@ -90,13 +101,15 @@ model UserRole {
}
model ApiToken {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token_hash String @unique
label String?
created_at DateTime @default(now())
revoked_at DateTime?
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token_hash String @unique
label String?
created_at DateTime @default(now())
revoked_at DateTime?
claimed_jobs ClaudeJob[]
claude_worker ClaudeWorker?
@@index([token_hash])
@@map("api_tokens")
@ -119,8 +132,9 @@ model Product {
stories Story[]
todos Todo[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
@@unique([user_id, name])
@@unique([user_id, code])
@ -225,12 +239,54 @@ model Task {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
@@index([story_id, priority, sort_order])
@@index([sprint_id, status])
@@map("tasks")
}
model ClaudeJob {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
claimed_at DateTime?
started_at DateTime?
finished_at DateTime?
branch String?
summary String?
error String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id, status])
@@index([task_id, status])
@@index([status, claimed_at])
@@map("claude_jobs")
}
model ClaudeWorker {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
token_id String
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
@@unique([token_id])
@@index([user_id, last_seen_at])
@@map("claude_workers")
}
model ProductMember {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)

View file

@ -1,8 +1,22 @@
import { create } from 'zustand'
import type { SoloTask } from '@/components/solo/solo-board'
import type { ClaudeJobStatusApi } from '@/lib/job-status'
type TaskStatus = SoloTask['status']
export interface JobState {
job_id: string
task_id: string
status: ClaudeJobStatusApi
branch?: string
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 }
// 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
// /api/realtime/solo (ST-802).
@ -42,6 +56,9 @@ interface SoloStore {
realtimeStatus: RealtimeStatus
showConnectingIndicator: boolean
claudeJobsByTaskId: Record<string, JobState>
connectedWorkers: number
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
@ -52,6 +69,13 @@ interface SoloStore {
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
initJobs: (jobs: JobState[]) => void
handleJobEvent: (event: ClaudeJobEvent) => void
setWorkers: (count: number) => void
incrementWorkers: () => void
decrementWorkers: () => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
@ -60,6 +84,8 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
pendingOps: new Set<string>(),
realtimeStatus: 'connecting',
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
@ -101,6 +127,43 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
return { realtimeStatus: status, showConnectingIndicator }
}),
initJobs: (jobs) =>
set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }),
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })),
handleJobEvent: (event) => {
const { job_id, task_id } = event
if (event.type === 'claude_job_enqueued') {
set((s) => ({
claudeJobsByTaskId: {
...s.claudeJobsByTaskId,
[task_id]: { job_id, task_id, status: 'queued' },
},
}))
return
}
if (event.type === 'claude_job_status') {
const { status, branch, summary, error } = event
if (status === 'cancelled') {
set((s) => {
const next = { ...s.claudeJobsByTaskId }
delete next[task_id]
return { claudeJobsByTaskId: next }
})
return
}
set((s) => ({
claudeJobsByTaskId: {
...s.claudeJobsByTaskId,
[task_id]: { job_id, task_id, status, branch, summary, error },
},
}))
}
},
handleRealtimeEvent: (event) => {
if (event.entity === 'task') {
const { id, op } = event