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

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