From c0e271af3e7a7a55f9f60b373c7a40e5421ca77d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 21:10:17 +0200 Subject: [PATCH] PBI-47: schema, pause_context Zod, resumePausedSprintRunAction, PAUSED-banner UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrum4Me-side counterpart of scrum4me-mcp@f7f5a48 (PBI-9 + PBI-47): - prisma migration: ClaudeJob.{base_sha,head_sha} + SprintRun.pause_context - lib/pause-context.ts: Zod schema + parsePauseContext + pauseReasonLabel helper; single source of truth for the JSON pause_context shape produced by the mcp sprint-run flow (MERGE_CONFLICT pause) - actions/sprint-runs.ts: resumePausedSprintRunAction — separate from the existing FAILED-resume flow, requires SprintRun.status === PAUSED, closes the linked ClaudeQuestion, clears pause_context, sets RUNNING/QUEUED based on whether a claim is still active - components/sprint/sprint-run-controls.tsx: PAUSED banner with reason label, PR link, conflict-files list (max 5 + "+N more"), Resume button with confirm() guard - app/(app)/products/[id]/sprint/page.tsx: load pause_context from active SprintRun and pass through to SprintRunControls All MD3 tokens (warning-container, on-warning-container, primary). No raw Tailwind utility colours. Tests: 532 passing across 72 files (Scrum4Me side). Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/sprint-runs.ts | 65 ++++++++++++++++++- app/(app)/products/[id]/sprint/page.tsx | 9 ++- components/sprint/sprint-run-controls.tsx | 58 +++++++++++++++++ lib/pause-context.ts | 30 +++++++++ .../migration.sql | 10 +++ prisma/schema.prisma | 3 + 6 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 lib/pause-context.ts create mode 100644 prisma/migrations/20260506182000_add_job_shas_and_pause_context/migration.sql diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts index 31bb51d..aa9a709 100644 --- a/actions/sprint-runs.ts +++ b/actions/sprint-runs.ts @@ -4,9 +4,10 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' -import type { Prisma } from '@prisma/client' +import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { parsePauseContext } from '@/lib/pause-context' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -241,6 +242,68 @@ export async function resumeSprintAction(input: unknown): Promise { 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 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, 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' }, + }) + } + + // RUNNING when there's still a claim active, otherwise QUEUED so the + // worker picks up the next job on its next claim. + const activeClaims = await tx.claudeJob.count({ + where: { sprint_run_id, status: { in: ['CLAIMED', 'RUNNING'] } }, + }) + + await tx.sprintRun.update({ + where: { id: sprint_run_id }, + data: { + status: activeClaims > 0 ? 'RUNNING' : 'QUEUED', + pause_context: Prisma.JsonNull, + }, + }) + + 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 +} + interface CancelResultOk { ok: true } diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index ddedcce..e535758 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -7,6 +7,7 @@ import { pbiStatusToApi } from '@/lib/task-status' import { SprintBoardClient } from '@/components/sprint/sprint-board-client' import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintRunControls } from '@/components/sprint/sprint-run-controls' +import { parsePauseContext } from '@/lib/pause-context' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' import type { Task } from '@/components/sprint/task-list' import { TaskDialog } from '@/app/_components/tasks/task-dialog' @@ -50,8 +51,13 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { sprint_id: sprint.id, status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, }, - select: { id: true, status: true }, + select: { id: true, status: true, pause_context: true }, + orderBy: { created_at: 'desc' }, }) + const pauseContext = + activeSprintRun?.status === 'PAUSED' + ? parsePauseContext(activeSprintRun.pause_context) + : null // Sprint stories with full task data and assignee const [sprintStories, productMembers] = await Promise.all([ @@ -163,6 +169,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { sprintStatus={sprint.status} activeSprintRunId={activeSprintRun?.id ?? null} activeSprintRunStatus={activeSprintRun?.status ?? null} + pauseContext={pauseContext} isDemo={isDemo} /> diff --git a/components/sprint/sprint-run-controls.tsx b/components/sprint/sprint-run-controls.tsx index 0ea8f47..b1d1b96 100644 --- a/components/sprint/sprint-run-controls.tsx +++ b/components/sprint/sprint-run-controls.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { startSprintRunAction, resumeSprintAction, + resumePausedSprintRunAction, cancelSprintRunAction, type PreFlightBlocker, } from '@/actions/sprint-runs' @@ -17,6 +18,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context' type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED' type SprintRunStatusValue = @@ -34,6 +36,7 @@ interface Props { sprintStatus: SprintStatusValue activeSprintRunId: string | null activeSprintRunStatus: SprintRunStatusValue + pauseContext: PauseContext | null isDemo: boolean } @@ -60,6 +63,7 @@ export function SprintRunControls({ sprintStatus, activeSprintRunId, activeSprintRunStatus, + pauseContext, isDemo, }: Props) { const [pending, startTransition] = useTransition() @@ -73,6 +77,8 @@ export function SprintRunControls({ const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun const canResume = sprintStatus === 'FAILED' + const canResumePaused = + activeSprintRunStatus === 'PAUSED' && pauseContext !== null const canCancel = hasActiveRun function handleStart() { @@ -101,6 +107,25 @@ export function SprintRunControls({ }) } + function handleResumePaused() { + if (!activeSprintRunId || !pauseContext) return + if ( + !confirm( + `Sprint hervatten? Bevestig dat het ${pauseReasonLabel( + pauseContext.pause_reason, + ).toLowerCase()} is opgelost.`, + ) + ) + return + startTransition(async () => { + const result = await resumePausedSprintRunAction({ + sprint_run_id: activeSprintRunId, + }) + if (result.ok) toast.success('Sprint hervat') + else toast.error(result.error) + }) + } + function handleCancel() { if (!activeSprintRunId) return if (!confirm('Sprint annuleren? Openstaande taken blijven TO_DO.')) return @@ -113,6 +138,39 @@ export function SprintRunControls({ return ( <> + {canResumePaused && pauseContext && ( +
+
+ Gepauzeerd: {pauseReasonLabel(pauseContext.pause_reason)} +
+ + {pauseContext.pr_url} + + {pauseContext.conflict_files.length > 0 && ( +
    + {pauseContext.conflict_files.slice(0, 5).map((f) => ( +
  • · {f}
  • + ))} + {pauseContext.conflict_files.length > 5 && ( +
  • · + {pauseContext.conflict_files.length - 5} meer
  • + )} +
+ )} + +
+ )}
{canStart && (