From 73087e9705abbe4ad53278ea95cb377cccd1e1f3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 19:51:48 +0200 Subject: [PATCH] =?UTF-8?q?M13:=20Claude=20job=20queue=20=E2=80=94=20'Voer?= =?UTF-8?q?=20uit'-knop=20+=20worker=20presence=20(ST-1111)=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ST-1111.1): add ClaudeJob model and state-machine enum Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.2): add ClaudeJob status API mappers Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.7): add job status pill with spinner on solo task cards Co-Authored-By: Claude Sonnet 4.6 * test(ST-1111.8): cover job-status mappers and enqueue/cancel actions Co-Authored-By: Claude Sonnet 4.6 * docs(ST-1111.9): document Claude job queue architecture and agent flow Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.10a): add ClaudeWorker presence model Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.10c): forward worker presence events on solo SSE + initial count Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 6 +- __tests__/actions/claude-jobs.test.ts | 165 ++++++++++++++++++ __tests__/lib/job-status.test.ts | 43 +++++ actions/claude-jobs.ts | 97 ++++++++++ app/api/realtime/solo/route.ts | 100 ++++++++++- components/shared/job-status.ts | 21 +++ components/solo/solo-board.tsx | 8 + components/solo/solo-task-card.tsx | 28 ++- components/solo/task-detail-dialog.tsx | 76 +++++++- docs/erd.svg | 2 +- docs/plans/ST-1111-claude-job-trigger.md | 69 ++++++++ docs/scrum4me-architecture.md | 50 ++++++ lib/job-status.ts | 32 ++++ lib/realtime/use-solo-realtime.ts | 38 +++- .../migration.sql | 43 +++++ .../migration.sql | 23 +++ prisma/schema.prisma | 84 +++++++-- stores/solo-store.ts | 63 +++++++ 18 files changed, 921 insertions(+), 27 deletions(-) create mode 100644 __tests__/actions/claude-jobs.test.ts create mode 100644 __tests__/lib/job-status.test.ts create mode 100644 actions/claude-jobs.ts create mode 100644 components/shared/job-status.ts create mode 100644 docs/plans/ST-1111-claude-job-trigger.md create mode 100644 lib/job-status.ts create mode 100644 prisma/migrations/20260429165857_add_claude_job/migration.sql create mode 100644 prisma/migrations/20260429171047_add_claude_worker/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index 61e57cf..6693080 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts new file mode 100644 index 0000000..fea9d8c --- /dev/null +++ b/__tests__/actions/claude-jobs.test.ts @@ -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' }) + }) +}) diff --git a/__tests__/lib/job-status.test.ts b/__tests__/lib/job-status.test.ts new file mode 100644 index 0000000..db8d1ab --- /dev/null +++ b/__tests__/lib/job-status.test.ts @@ -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) + }) +}) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts new file mode 100644 index 0000000..fa9a1e8 --- /dev/null +++ b/actions/claude-jobs.ts @@ -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 { + 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 { + 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 } +} diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index ba68b63..4e93ba8 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -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 { + const { prisma } = await import('@/lib/prisma') + return prisma.claudeWorker.count({ + where: { + user_id: userId, + last_seen_at: { gt: new Date(Date.now() - 15_000) }, + }, + }) +} diff --git a/components/shared/job-status.ts b/components/shared/job-status.ts new file mode 100644 index 0000000..06b8ecf --- /dev/null +++ b/components/shared/job-status.ts @@ -0,0 +1,21 @@ +import type { ClaudeJobStatusApi } from '@/lib/job-status' + +export const JOB_STATUS_LABELS: Record = { + queued: 'Wacht…', + claimed: 'Geclaimd…', + running: 'Bezig…', + done: 'Klaar', + failed: 'Mislukt', + cancelled: 'Geannuleerd', +} + +export const JOB_STATUS_COLORS: Record = { + 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(['queued', 'claimed', 'running']) diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 934f984..fb50be8 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -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(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) @@ -192,6 +193,13 @@ export function SoloBoard({ status={realtimeStatus} showConnectingIndicator={showConnectingIndicator} /> +
+ 0 ? 'bg-status-done' : 'bg-muted-foreground/40' + )} /> + {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} +
{sprintGoal && (

{sprintGoal}

diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx index 61135c8..6289d57 100644 --- a/components/solo/solo-task-card.tsx +++ b/components/solo/solo-task-card.tsx @@ -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 = { @@ -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) {

{task.title}

{task.task_code && } -

- {task.story_code && {task.story_code}} - {task.story_title} -

+
+

+ {task.story_code && {task.story_code}} + {task.story_title} +

+ {job && ( + { e.stopPropagation(); onClick() }} + role="button" + aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`} + > + {JOB_STATUS_ACTIVE.has(job.status) && } + {JOB_STATUS_LABELS[job.status]} + + )} +
) } diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index b37be68..d9b6db1 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -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('idle') const [, startTransition] = useTransition() + const [jobPending, startJobTransition] = useTransition() const fadeTimer = useRef | 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 -
+
Open in Sprint Board ↗ + + {!isDemo && !job && ( + + + + Voer uit + + } + /> + {connectedWorkers === 0 && ( + + Geen Claude Code-sessie verbonden. Start claude lokaal en zeg 'wacht op jobs'. + + )} + + + )} + + {job?.status === 'queued' && ( + Wacht op agent… + )} + + {(job?.status === 'claimed' || job?.status === 'running') && ( + <> + Bezig: {job.summary ?? '…'} + + + )} + + {job?.status === 'done' && ( + + Klaar{job.branch ? ` — branch ${job.branch}` : ''} + + )} + + {job?.status === 'failed' && ( + Mislukt: {job.error} + )}
) diff --git a/docs/erd.svg b/docs/erd.svg index ec45d14..fd4b3eb 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

user

product

task

enum:status

claimed_by_token

user

token

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

String

branch

String

summary

String

error

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/docs/plans/ST-1111-claude-job-trigger.md b/docs/plans/ST-1111-claude-job-trigger.md new file mode 100644 index 0000000..1e8d0ab --- /dev/null +++ b/docs/plans/ST-1111-claude-job-trigger.md @@ -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. diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index 02da55b..bc244f5 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -1047,6 +1047,56 @@ Patroon: **Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — 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 | diff --git a/lib/job-status.ts b/lib/job-status.ts new file mode 100644 index 0000000..f6ac4ee --- /dev/null +++ b/lib/job-status.ts @@ -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 + +const JOB_API_TO_DB: Record = { + 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'] diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 6f0340f..928dd80 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -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 diff --git a/prisma/migrations/20260429165857_add_claude_job/migration.sql b/prisma/migrations/20260429165857_add_claude_job/migration.sql new file mode 100644 index 0000000..7360c3f --- /dev/null +++ b/prisma/migrations/20260429165857_add_claude_job/migration.sql @@ -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; diff --git a/prisma/migrations/20260429171047_add_claude_worker/migration.sql b/prisma/migrations/20260429171047_add_claude_worker/migration.sql new file mode 100644 index 0000000..f951db3 --- /dev/null +++ b/prisma/migrations/20260429171047_add_claude_worker/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07c5816..6586980 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 39c3500..46cf569 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -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 + 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((set, get) => ({ pendingOps: new Set(), 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((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