diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index ba68b63..539eb17 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,34 @@ 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 NotifyPayload = EntityPayload | JobPayload + +function isJobPayload(p: NotifyPayload): p is JobPayload { + return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') +} + function shouldEmit( payload: NotifyPayload, productId: string, activeSprintId: string | null, userId: string, ): boolean { + if (isJobPayload(payload)) { + return payload.user_id === userId && payload.product_id === productId + } + // M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier. if (payload.entity === 'question') return false @@ -159,6 +181,12 @@ 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`) + } + // Heartbeat als SSE-comment — voorkomt proxy-timeouts heartbeatTimer = setInterval(() => { enqueue(`: heartbeat\n\n`) @@ -186,8 +214,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 +222,32 @@ 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, + })) +} diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 6f0340f..1bb8baa 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,8 @@ 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 if (!productId) { // Geen actief product (gebruiker zit niet op /solo) — stream uit @@ -84,10 +86,24 @@ 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.onmessage = (e) => { if (!e.data) return try { - const payload = JSON.parse(e.data) as RealtimeEvent + const raw = JSON.parse(e.data) as RealtimeEvent | ClaudeJobEvent + if ('type' in raw && (raw.type === 'claude_job_enqueued' || raw.type === 'claude_job_status')) { + handleJobEvent(raw) + 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/stores/solo-store.ts b/stores/solo-store.ts index 39c3500..c26f547 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,8 @@ interface SoloStore { realtimeStatus: RealtimeStatus showConnectingIndicator: boolean + claudeJobsByTaskId: Record + initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void @@ -52,6 +68,9 @@ interface SoloStore { setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void + initJobs: (jobs: JobState[]) => void + handleJobEvent: (event: ClaudeJobEvent) => void + handleRealtimeEvent: (event: RealtimeEvent) => void } @@ -60,6 +79,7 @@ export const useSoloStore = create((set, get) => ({ pendingOps: new Set(), realtimeStatus: 'connecting', showConnectingIndicator: false, + claudeJobsByTaskId: {}, initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -101,6 +121,39 @@ export const useSoloStore = create((set, get) => ({ return { realtimeStatus: status, showConnectingIndicator } }), + initJobs: (jobs) => + set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }), + + 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