'use server' import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { parsePauseContext } from '@/lib/pause-context' import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' async function getSession() { return getIronSession(await cookies(), sessionOptions) } export type PreFlightBlockerType = | 'task_no_plan' | 'open_question' | 'pbi_blocked' | 'task_cross_repo' export interface PreFlightBlocker { type: PreFlightBlockerType id: string label: string } const StartSprintRunInput = z.object({ sprint_id: z.string().min(1) }) const ResumeSprintInput = z.object({ sprint_id: z.string().min(1) }) const CancelSprintRunInput = z.object({ sprint_run_id: z.string().min(1) }) interface StartResultOk { ok: true sprint_run_id: string jobs_count: number } interface StartResultBlocked { ok: false error: 'PRE_FLIGHT_BLOCKED' blockers: PreFlightBlocker[] } interface ErrorResult { ok: false error: string code: number } type StartResult = StartResultOk | StartResultBlocked | ErrorResult // startSprintRunCore is gedeeld tussen startSprintRunAction en resumeSprintAction. // Voert de pre-flight uit, maakt een SprintRun + ClaudeJobs (in PBI→Story→Task // volgorde) binnen één transactie. Aanroeper levert sprint_id, user_id en de // transactionele Prisma-client. async function startSprintRunCore( tx: Prisma.TransactionClient, sprint_id: string, user_id: string, ): Promise { const sprint = await tx.sprint.findUnique({ where: { id: sprint_id }, include: { product: true }, }) if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 } if (sprint.status !== 'OPEN') return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 } const activeRun = await tx.sprintRun.findFirst({ where: { sprint_id, status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, }, }) if (activeRun) return { ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE', code: 409 } const stories = await tx.story.findMany({ where: { sprint_id, status: { not: 'DONE' } }, include: { pbi: true, tasks: { // EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt // TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet // terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen. where: { status: 'TO_DO' }, orderBy: [{ sort_order: 'asc' }], }, }, orderBy: [{ sort_order: 'asc' }], }) const blockers: PreFlightBlocker[] = [] for (const s of stories) { for (const t of s.tasks) { if (!t.implementation_plan) { blockers.push({ type: 'task_no_plan', id: t.id, label: `${t.code}: ${t.title}`, }) } } } const openQuestions = await tx.claudeQuestion.findMany({ where: { story: { sprint_id }, status: 'open' }, select: { id: true, question: true }, }) for (const q of openQuestions) { blockers.push({ type: 'open_question', id: q.id, label: q.question.slice(0, 80), }) } const seenPbi = new Set() for (const s of stories) { if (seenPbi.has(s.pbi.id)) continue seenPbi.add(s.pbi.id) if (s.pbi.status === 'BLOCKED' || s.pbi.status === 'FAILED') { blockers.push({ type: 'pbi_blocked', id: s.pbi.id, label: `${s.pbi.code}: ${s.pbi.title}`, }) } } // PBI-50: SPRINT_BATCH cross-repo blocker. Eén product-worktree = // alle tasks moeten in product.repo_url werken; task.repo_url-override // is incompatibel met deze flow. if (sprint.product.pr_strategy === 'SPRINT_BATCH') { for (const s of stories) { for (const t of s.tasks) { if (t.repo_url && t.repo_url !== sprint.product.repo_url) { blockers.push({ type: 'task_cross_repo', id: t.id, label: `${t.code}: ${t.title}`, }) } } } } if (blockers.length > 0) { return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers } } const sprintRun = await tx.sprintRun.create({ data: { sprint_id, started_by_id: user_id, status: 'QUEUED', pr_strategy: sprint.product.pr_strategy, started_at: new Date(), }, }) const orderedTasks = stories .slice() .sort( (a, b) => a.pbi.priority - b.pbi.priority || a.pbi.sort_order - b.pbi.sort_order || a.sort_order - b.sort_order, ) .flatMap((s) => s.tasks) // PBI-50: SPRINT_BATCH levert één SPRINT_IMPLEMENTATION-job die alle // tasks in één claude-sessie afhandelt. SprintTaskExecution-rows worden // server-side bij claim aangemaakt zodat order/base_sha consistent zijn // met de worktree-state op claim-tijd. if (sprint.product.pr_strategy === 'SPRINT_BATCH') { const sprintSnapshot = await getJobConfigSnapshot({ kind: 'SPRINT_IMPLEMENTATION', productId: sprint.product_id, }) await tx.claudeJob.create({ data: { user_id, product_id: sprint.product_id, task_id: null, idea_id: null, sprint_run_id: sprintRun.id, kind: 'SPRINT_IMPLEMENTATION', status: 'QUEUED', ...sprintSnapshot, }, }) return { ok: true, sprint_run_id: sprintRun.id, jobs_count: 1 } } // STORY / SPRINT (per-task): bestaand pad. Snapshot per task zodat // task.requires_opus de cascade kan overrulen. for (const t of orderedTasks) { const taskSnapshot = await getJobConfigSnapshot({ kind: 'TASK_IMPLEMENTATION', productId: sprint.product_id, taskId: t.id, }) await tx.claudeJob.create({ data: { user_id, product_id: sprint.product_id, task_id: t.id, sprint_run_id: sprintRun.id, kind: 'TASK_IMPLEMENTATION', status: 'QUEUED', ...taskSnapshot, }, }) } return { ok: true, sprint_run_id: sprintRun.id, jobs_count: orderedTasks.length } } export async function startSprintRunAction(input: unknown): Promise { const session = await getSession() if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = StartSprintRunInput.safeParse(input) if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } const userId = session.userId const result = await prisma.$transaction((tx) => startSprintRunCore(tx, parsed.data.sprint_id, userId), ) if (result.ok) { revalidatePath(`/sprints/${parsed.data.sprint_id}`) } return result } export async function resumeSprintAction(input: unknown): Promise { const session = await getSession() if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = ResumeSprintInput.safeParse(input) if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } const userId = session.userId const sprint_id = parsed.data.sprint_id const result = await prisma.$transaction(async (tx) => { const sprint = await tx.sprint.findUnique({ where: { id: sprint_id } }) if (!sprint) return { ok: false as const, error: 'SPRINT_NOT_FOUND', code: 404 } if (sprint.status !== 'FAILED') return { ok: false as const, error: 'SPRINT_NOT_FAILED', code: 400 } // Sprint terug naar ACTIVE await tx.sprint.update({ where: { id: sprint_id }, data: { status: 'OPEN', completed_at: null }, }) // FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft) await tx.story.updateMany({ where: { sprint_id, status: 'FAILED' }, data: { status: 'IN_SPRINT' }, }) // PBIs van die stories: FAILED → READY (BLOCKED met rust laten) const storyPbiIds = ( await tx.story.findMany({ where: { sprint_id }, select: { pbi_id: true }, distinct: ['pbi_id'], }) ).map((s) => s.pbi_id) await tx.pbi.updateMany({ where: { id: { in: storyPbiIds }, status: 'FAILED' }, data: { status: 'READY' }, }) // FAILED tasks → TO_DO (DONE blijft) await tx.task.updateMany({ where: { story: { sprint_id }, status: 'FAILED' }, data: { status: 'TO_DO' }, }) return startSprintRunCore(tx, sprint_id, userId) }) if (result.ok) { revalidatePath(`/sprints/${sprint_id}`) } return result } const ResumePausedSprintRunInput = z.object({ sprint_run_id: z.string().min(1) }) interface ResumePausedResultOk { ok: true } type ResumePausedResult = ResumePausedResultOk | ErrorResult export async function resumePausedSprintRunAction( input: unknown, ): Promise { const session = await getSession() if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = ResumePausedSprintRunInput.safeParse(input) if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } const sprint_run_id = parsed.data.sprint_run_id const userId = session.userId const result = await prisma.$transaction(async (tx) => { const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id }, select: { id: true, status: true, sprint_id: true, pr_strategy: true, branch: true, pause_context: true, }, }) if (!run) return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 } if (run.status !== 'PAUSED') return { ok: false as const, error: 'SPRINT_RUN_NOT_PAUSED', code: 400 } const ctx = parsePauseContext(run.pause_context) if (ctx) { await tx.claudeQuestion.updateMany({ where: { id: ctx.claude_question_id, status: 'open' }, data: { status: 'closed' }, }) } // PBI-50: SPRINT_BATCH resume-pad — als de SprintRun hangt aan een // SPRINT_IMPLEMENTATION-job en er nog onafgemaakte SprintTaskExecution-rows // zijn (PENDING/RUNNING), maak NIEUWE SprintRun met previous_run_id + // hergebruikte branch + nieuwe SPRINT_IMPLEMENTATION-job. Oude SprintRun // gaat naar CANCELLED. const sprintJob = await tx.claudeJob.findFirst({ where: { sprint_run_id, kind: 'SPRINT_IMPLEMENTATION' }, select: { id: true, product_id: true }, }) if (sprintJob) { const remaining = await tx.sprintTaskExecution.count({ where: { sprint_job_id: sprintJob.id, status: { in: ['PENDING', 'RUNNING'] }, }, }) if (remaining > 0) { const newRun = await tx.sprintRun.create({ data: { sprint_id: run.sprint_id, started_by_id: userId, status: 'QUEUED', pr_strategy: run.pr_strategy, branch: run.branch, previous_run_id: run.id, started_at: new Date(), }, }) const resumeSnapshot = await getJobConfigSnapshot({ kind: 'SPRINT_IMPLEMENTATION', productId: sprintJob.product_id, }) await tx.claudeJob.create({ data: { user_id: userId, product_id: sprintJob.product_id, task_id: null, idea_id: null, sprint_run_id: newRun.id, kind: 'SPRINT_IMPLEMENTATION', status: 'QUEUED', ...resumeSnapshot, }, }) await tx.sprintRun.update({ where: { id: sprint_run_id }, data: { status: 'CANCELLED', pause_context: Prisma.JsonNull, finished_at: new Date(), }, }) return { ok: true as const, sprint_id: run.sprint_id, finalStatus: 'QUEUED' as const } } } const activeClaims = await tx.claudeJob.count({ where: { sprint_run_id, status: { in: ['CLAIMED', 'RUNNING'] } }, }) const queuedJobs = await tx.claudeJob.count({ where: { sprint_run_id, status: 'QUEUED' }, }) // PBI-49 P0: een STORY auto-merge MERGE_CONFLICT komt NA dat alle tasks // al DONE zijn. Terug naar QUEUED zou de SprintRun voor altijd laten // hangen — geen QUEUED job. Bij volledige scope-completion transitie // direct naar DONE; de dev heeft het conflict opgelost, de PR is van hen. let nextStatus: 'RUNNING' | 'QUEUED' | 'DONE' let finishedAt: Date | undefined if (activeClaims === 0 && queuedJobs === 0) { nextStatus = 'DONE' finishedAt = new Date() } else if (activeClaims > 0) { nextStatus = 'RUNNING' } else { nextStatus = 'QUEUED' } await tx.sprintRun.update({ where: { id: sprint_run_id }, data: { status: nextStatus, pause_context: Prisma.JsonNull, ...(finishedAt ? { finished_at: finishedAt } : {}), }, }) return { ok: true as const, sprint_id: run.sprint_id, finalStatus: nextStatus } }) if (result.ok && 'sprint_id' in result) { revalidatePath(`/sprints/${result.sprint_id}`) return { ok: true } } return result } interface CancelResultOk { ok: true } type CancelResult = CancelResultOk | ErrorResult export async function cancelSprintRunAction(input: unknown): Promise { const session = await getSession() if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = CancelSprintRunInput.safeParse(input) if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } const sprint_run_id = parsed.data.sprint_run_id const result = await prisma.$transaction(async (tx) => { const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id } }) if (!run) return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 } if (!['QUEUED', 'RUNNING', 'PAUSED'].includes(run.status)) return { ok: false as const, error: 'SPRINT_RUN_NOT_CANCELLABLE', code: 400 } await tx.sprintRun.update({ where: { id: sprint_run_id }, data: { status: 'CANCELLED', finished_at: new Date() }, }) // Cancel openstaande task-jobs binnen deze run. // Tasks/Stories/PBIs/Sprint blijven hun status — cancel ≠ fail. await tx.claudeJob.updateMany({ where: { sprint_run_id, status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, }, data: { status: 'CANCELLED', finished_at: new Date(), }, }) return { ok: true as const, sprint_id: run.sprint_id } }) if (result.ok && 'sprint_id' in result) { revalidatePath(`/sprints/${result.sprint_id}`) return { ok: true } } return result }