From 3c773421dacaf506bf35a8270249822cf509ccf3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sat, 9 May 2026 12:47:38 +0200 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20bump=201.3.0=20=E2=86=92=201.3.1?= =?UTF-8?q?=20(force=20Vercel-redeploy)=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggert Vercel-rebuild zodat lib/job-config.ts (KIND_DEFAULTS uit #171 en permission_mode-fix uit #172) actief wordt in de live webapp. De enqueue-laag (lib/job-config-snapshot.ts) snapshot dan correct permission_mode='acceptEdits' voor idea-kinds + PLAN_CHAT. Symptoom dat dit oplost: in een lokale smoke-test (na merge van #171 en #172) bleek dat een vers enqueued IDEA_MAKE_PLAN-job nog requested_permission_mode='plan' kreeg — wat duidt op een Vercel-deploy die nog op de oude bundle stond. Met deze bump wordt de redeploy geforceerd. Co-authored-by: Claude Opus 4.7 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06db5c5..9ec48d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me", - "version": "1.3.0", + "version": "1.3.1", "hasInstallScript": true, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/package.json b/package.json index f6a7b40..7adc14e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me", - "version": "1.3.0", + "version": "1.3.1", "private": true, "scripts": { "predev": "npx --yes kill-port 3000 || exit 0", From 35e37dac09d2863b9274824c39a9b2be640ebd92 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sat, 9 May 2026 13:59:06 +0200 Subject: [PATCH 2/4] feat(ST-006): voeg restartClaudeJobAction toe aan actions/claude-jobs.ts (#174) - Exporteert restartClaudeJobAction(jobId) die FAILED/CANCELLED/SKIPPED jobs atomair reset naar QUEUED - Valideert auth, demo-blokkade, ownership en restartbare status - Gebruikt prisma.$transaction: claudeJob.updateMany + conditionale sprintTaskExecution.updateMany reset - Verstuurt pg_notify claude_job_status zodat Jobs-pagina via SSE ververst - Unit-tests: happy-path (FAILED/CANCELLED/SKIPPED), demo-blokkade, not-found, niet-restartbare status, race-conditie en sprint sub-task reset --- __tests__/actions/claude-jobs.test.ts | 147 ++++++++++++++++++++++++-- actions/claude-jobs.ts | 77 ++++++++++++++ 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 5e95878..484f185 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -8,13 +8,24 @@ const { mockGetSession, mockFindFirstJob, mockUpdateJob, + mockUpdateManyJob, + mockUpdateManySprintTaskExecution, + mockTransaction, mockExecuteRaw, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockFindFirstJob: vi.fn(), - mockUpdateJob: vi.fn(), - mockExecuteRaw: vi.fn().mockResolvedValue(undefined), -})) +} = vi.hoisted(() => { + const mockUpdateManyJob = vi.fn() + const mockUpdateManySprintTaskExecution = vi.fn() + const mockTransaction = vi.fn() + return { + mockGetSession: vi.fn(), + mockFindFirstJob: vi.fn(), + mockUpdateJob: vi.fn(), + mockUpdateManyJob, + mockUpdateManySprintTaskExecution, + mockTransaction, + mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + } +}) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) @@ -23,7 +34,12 @@ vi.mock('@/lib/prisma', () => ({ claudeJob: { findFirst: mockFindFirstJob, update: mockUpdateJob, + updateMany: mockUpdateManyJob, }, + sprintTaskExecution: { + updateMany: mockUpdateManySprintTaskExecution, + }, + $transaction: mockTransaction, $executeRaw: mockExecuteRaw, }, })) @@ -32,6 +48,7 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, + restartClaudeJobAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -39,6 +56,12 @@ const SESSION_USER = { userId: 'user-1', isDemo: false } beforeEach(() => { vi.clearAllMocks() mockExecuteRaw.mockResolvedValue(undefined) + mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + claudeJob: { updateMany: mockUpdateManyJob }, + sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution }, + }) + ) }) describe('enqueueClaudeJobAction (deprecated)', () => { @@ -104,3 +127,115 @@ describe('cancelClaudeJobAction', () => { expect(result).toMatchObject({ error: expect.stringContaining('actieve') }) }) }) + +describe('restartClaudeJobAction', () => { + const FAILED_JOB = { + id: 'job-1', + status: 'FAILED', + kind: 'TASK_IMPLEMENTATION', + task_id: 'task-1', + idea_id: null, + sprint_run_id: null, + product_id: 'prod-1', + } + + it('reset een FAILED job naar QUEUED (happy path)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(FAILED_JOB) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + const result = await restartClaudeJobAction('job-1') + + expect(result).toEqual({ success: true }) + expect(mockUpdateManyJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'job-1', status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] } }), + data: expect.objectContaining({ status: 'QUEUED' }), + }) + ) + expect(mockExecuteRaw).toHaveBeenCalled() + }) + + it('reset een CANCELLED job naar QUEUED', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'CANCELLED' }) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toEqual({ success: true }) + }) + + it('reset een SKIPPED job naar QUEUED', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' }) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toEqual({ success: true }) + }) + + it('weigert demo-sessie', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockUpdateManyJob).not.toHaveBeenCalled() + }) + + it('retourneert error als job niet gevonden', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(null) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) + }) + + it('weigert wanneer job een niet-restartbare status heeft', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'DONE' }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('mislukte') }) + expect(mockUpdateManyJob).not.toHaveBeenCalled() + }) + + it('retourneert error bij race-conditie (updateMany count === 0)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(FAILED_JOB) + mockUpdateManyJob.mockResolvedValue({ count: 0 }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('gewijzigd') }) + }) + + it('reset ook SprintTaskExecution-rows bij SPRINT_IMPLEMENTATION', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ + ...FAILED_JOB, + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'run-1', + }) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + mockUpdateManySprintTaskExecution.mockResolvedValue({ count: 3 }) + + const result = await restartClaudeJobAction('job-1') + + expect(result).toEqual({ success: true }) + expect(mockUpdateManySprintTaskExecution).toHaveBeenCalledWith( + expect.objectContaining({ + where: { sprint_job_id: 'job-1' }, + data: expect.objectContaining({ status: 'PENDING' }), + }) + ) + }) + + it('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(FAILED_JOB) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + await restartClaudeJobAction('job-1') + + expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled() + }) +}) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 12fa3e9..258fd1a 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -1,6 +1,7 @@ 'use server' import { revalidatePath } from 'next/cache' +import { type ClaudeJobStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' @@ -15,6 +16,9 @@ type EnqueueAllResult = type CancelResult = { success: true } | { error: string } +type RestartResult = { success: true } | { error: string } +const RESTARTABLE_STATUSES: ClaudeJobStatus[] = ['FAILED', 'CANCELLED', 'SKIPPED'] + export type PreviewTask = { id: string title: string @@ -109,3 +113,76 @@ 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, kind: true, task_id: true, idea_id: true, sprint_run_id: true, product_id: true }, + }) + if (!job) return { error: 'Job niet gevonden' } + if (!RESTARTABLE_STATUSES.includes(job.status)) { + return { error: 'Alleen mislukte, geannuleerde of overgeslagen jobs kunnen opnieuw gestart worden' } + } + + const updated = await prisma.$transaction(async (tx) => { + const result = await tx.claudeJob.updateMany({ + where: { id: jobId, status: { in: RESTARTABLE_STATUSES } }, + data: { + status: 'QUEUED', + retry_count: { increment: 1 }, + claimed_by_token_id: null, + claimed_at: null, + started_at: null, + finished_at: null, + pushed_at: null, + verify_result: null, + error: null, + summary: null, + branch: null, + head_sha: null, + lease_until: null, + }, + }) + if (result.count === 0) return 0 + if (job.kind === 'SPRINT_IMPLEMENTATION') { + await tx.sprintTaskExecution.updateMany({ + where: { sprint_job_id: jobId }, + data: { + status: 'PENDING', + verify_result: null, + verify_summary: null, + skip_reason: null, + head_sha: null, + started_at: null, + finished_at: null, + }, + }) + } + return result.count + }) + if (updated === 0) { + return { error: 'Job-status is gewijzigd; herlaad en probeer opnieuw' } + } + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: jobId, + kind: job.kind, + task_id: job.task_id, + idea_id: job.idea_id, + sprint_run_id: job.sprint_run_id, + user_id: session.userId, + product_id: job.product_id, + status: jobStatusToApi('QUEUED'), + })}::text) + ` + + revalidatePath('/jobs') + return { success: true } +} From 71319e629d39a67cd1ede0f354ec389ac556c8bc Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sat, 9 May 2026 16:27:24 +0200 Subject: [PATCH 3/4] feat(PBI-71): UX-fix 'lege sprint' + sprint-switch data-refresh (#175) - StartSprintButton dialog toont 3-state banner: info met accurate vrije- stories count + PBI-context, of waarschuwing als geen PBI geselecteerd is, of waarschuwing als de geselecteerde PBI 0 vrije stories heeft - Voeg sprint_id toe aan BacklogStory/Story/SprintStory + select in PB- pagina's en sprint-board mappings, zodat de banner accuraat kan tellen - createSprintAction: revalidatePath met 'layout' flag voor consistency met createSprintWithPbisAction (top-nav 'Sprint' link ververst direct) Sprint-switch data-refresh op alle relevante pagina's: - BacklogHydrationWrapper: fingerprint-based re-hydratie zodat PB-data na router.refresh opnieuw uit nieuwe initialData komt (was: useEffect met lege deps draaide alleen 1x) - SprintBoardClient: key={sprint.id} forceert remount bij sprint-switch zodat lokale sprintStories/sprintStoryIds-state vers ge-init wordt - Solo (desktop + mobile): gebruik resolveActiveSprint(id) ipv eerste OPEN-sprint, plus key={sprint.id} op SoloBoard voor remount Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/api/backlog-realtime.test.ts | 4 +-- .../components/backlog/integration.test.tsx | 2 +- __tests__/realtime/payload-contract.test.ts | 1 + actions/sprints.ts | 2 +- app/(app)/products/[id]/page.tsx | 1 + app/(app)/products/[id]/solo/page.tsx | 9 ++++-- .../products/[id]/sprint/[sprintId]/page.tsx | 3 ++ app/(mobile)/m/products/[id]/page.tsx | 1 + app/(mobile)/m/products/[id]/solo/page.tsx | 9 ++++-- .../backlog/backlog-hydration-wrapper.tsx | 23 ++++++++++++--- components/backlog/story-panel.tsx | 1 + components/sprint/sprint-backlog.tsx | 1 + components/sprint/start-sprint-button.tsx | 28 +++++++++++++++++++ stores/backlog-store.ts | 1 + 14 files changed, 72 insertions(+), 14 deletions(-) diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts index 4898cda..f9d0bfe 100644 --- a/__tests__/api/backlog-realtime.test.ts +++ b/__tests__/api/backlog-realtime.test.ts @@ -110,13 +110,13 @@ describe('shouldEmit scope filter (via backlog-store reducer)', () => { it('applyChange: story INSERT adds to storiesByPbi', () => { useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() } useBacklogStore.getState().applyChange('story', 'I', story) expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) }) it('applyChange: story DELETE removes from correct pbi bucket', () => { - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() } useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx index 928ccce..feab76c 100644 --- a/__tests__/components/backlog/integration.test.tsx +++ b/__tests__/components/backlog/integration.test.tsx @@ -62,7 +62,7 @@ const ALT_PBI_ID = 'pbi-2' const STORY_ID = 'story-1' const STORIES = [ - { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() }, ] const TASKS = [ { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts index b36bc09..3835903 100644 --- a/__tests__/realtime/payload-contract.test.ts +++ b/__tests__/realtime/payload-contract.test.ts @@ -21,6 +21,7 @@ const STORY: BacklogStory = { priority: 2, status: 'OPEN', pbi_id: 'pbi-1', + sprint_id: null, created_at: new Date('2024-01-01T00:00:00Z'), } diff --git a/actions/sprints.ts b/actions/sprints.ts index de096d3..1be5ef5 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -103,7 +103,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData } await setActiveSprintCookie(parsed.data.productId, sprint.id) - revalidatePath(`/products/${parsed.data.productId}`) + revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index a8e79e5..8731a53 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -61,6 +61,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props priority: true, status: true, pbi_id: true, + sprint_id: true, created_at: true, }, }), diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 8af037d..83a1720 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' +import { resolveActiveSprint } from '@/lib/active-sprint' import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { SoloBoard } from '@/components/solo/solo-board' import { NoActiveSprint } from '@/components/solo/no-active-sprint' @@ -21,9 +22,10 @@ export default async function SoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN' }, - }) + const active = await resolveActiveSprint(id) + const sprint = active + ? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } }) + : null const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null }) @@ -126,6 +128,7 @@ export default async function SoloProductPage({ params }: Props) { {switcherBar}
`${p.id}:${p.status}:${p.priority}`).join(',') + const storyPart = Object.entries(data.storiesByPbi) + .flatMap(([, list]) => list.map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}`)) + .join(',') + const taskPart = Object.entries(data.tasksByStory) + .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}`)) + .join(',') + return `${pbiPart}|${storyPart}|${taskPart}` +} + export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { const setInitialData = useBacklogStore((s) => s.setInitialData) + const lastFingerprint = useRef('') useEffect(() => { - setInitialData(initialData) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const fp = fingerprint(initialData) + if (fp !== lastFingerprint.current) { + lastFingerprint.current = fp + setInitialData(initialData) + } + }, [initialData, setInitialData]) useBacklogRealtime(productId) diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 78fa2ad..9707a62 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -56,6 +56,7 @@ export interface Story { priority: number status: string pbi_id: string + sprint_id: string | null created_at: Date } diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index e65f363..30b98e2 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -41,6 +41,7 @@ export interface SprintStory { description: string | null acceptance_criteria: string | null pbi_id: string + sprint_id: string | null created_at: Date priority: number status: string diff --git a/components/sprint/start-sprint-button.tsx b/components/sprint/start-sprint-button.tsx index fcebf17..08dacad 100644 --- a/components/sprint/start-sprint-button.tsx +++ b/components/sprint/start-sprint-button.tsx @@ -22,6 +22,7 @@ import { } from '@/components/shared/entity-dialog-layout' import { createSprintAction } from '@/actions/sprints' import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' interface StartSprintButtonProps { productId: string @@ -46,6 +47,13 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt const formRef = useRef(null) const router = useRouter() const selectedPbiId = useSelectionStore((s) => s.selectedPbiId) + const selectedPbi = useBacklogStore((s) => + selectedPbiId ? s.pbis.find((p) => p.id === selectedPbiId) ?? null : null, + ) + const freeStoryCount = useBacklogStore((s) => { + if (!selectedPbiId) return 0 + return (s.storiesByPbi[selectedPbiId] ?? []).filter((story) => story.sprint_id === null).length + }) const [state, formAction, pending] = useActionState( async (_prev, fd) => { @@ -96,6 +104,26 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt {selectedPbiId && } + {!selectedPbi ? ( +
+ Geen PBI geselecteerd — de sprint wordt leeg aangemaakt. Je kunt later stories + toevoegen via slepen. +
+ ) : freeStoryCount === 0 ? ( +
+ PBI {selectedPbi.code ?? selectedPbi.id.slice(0, 8)} heeft geen + vrije stories (alle stories zitten al in een andere sprint of zijn afgerond) — de + sprint wordt leeg aangemaakt. +
+ ) : ( +
+ {freeStoryCount} {freeStoryCount === 1 ? 'story' : 'stories'} van + PBI {selectedPbi.code ?? selectedPbi.id.slice(0, 8)} + {selectedPbi.title ? ` (${selectedPbi.title})` : ''} worden toegevoegd aan deze + sprint. +
+ )} +
- +
) diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx index 7a691c1..9063113 100644 --- a/components/jobs/job-detail-pane.tsx +++ b/components/jobs/job-detail-pane.tsx @@ -1,9 +1,16 @@ 'use client' +import { useTransition } from 'react' +import { toast } from 'sonner' import { cn } from '@/lib/utils' import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' import { jobStatusToApi } from '@/lib/job-status' import type { JobWithRelations } from '@/actions/jobs-page' +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { restartClaudeJobAction } from '@/actions/claude-jobs' + +const RESTARTABLE_API_STATUSES = new Set(['failed', 'cancelled', 'skipped']) interface FieldRowProps { label: string @@ -42,9 +49,12 @@ function subjectLabel(job: JobWithRelations): { label: string; value: string } | interface JobDetailPaneProps { job: JobWithRelations | null + isDemo: boolean } -export default function JobDetailPane({ job }: JobDetailPaneProps) { +export default function JobDetailPane({ job, isDemo }: JobDetailPaneProps) { + const [isPending, startTransition] = useTransition() + if (!job) { return (
@@ -55,6 +65,14 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { const apiStatus = jobStatusToApi(job.status) const subject = subjectLabel(job) + const canRestart = RESTARTABLE_API_STATUSES.has(apiStatus) + + function handleRestart() { + startTransition(async () => { + const result = await restartClaudeJobAction(job!.id) + if ('error' in result) toast.error(result.error) + }) + } return (
@@ -110,6 +128,19 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) {

Geen beschrijving.

)}
+ {canRestart && ( +
+ + + +
+ )}
) } diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 6fd3024..f416e6b 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -15,6 +15,7 @@ import type { JobWithRelations } from '@/actions/jobs-page' interface JobsBoardProps { initialActiveJobs: JobWithRelations[] initialDoneJobs: JobWithRelations[] + isDemo: boolean } type View = 'detail' | 'usage' @@ -32,7 +33,7 @@ const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = { value: 'skipped', label: 'Overgeslagen' }, ] -export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { +export default function JobsBoard({ initialActiveJobs, initialDoneJobs, isDemo }: JobsBoardProps) { const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() const [view, setView] = useState('detail') useJobsRealtime() @@ -77,7 +78,7 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo
- {view === 'detail' ? : } + {view === 'detail' ? : }
) diff --git a/docs/specs/functional.md b/docs/specs/functional.md index e649ee3..436fafe 100644 --- a/docs/specs/functional.md +++ b/docs/specs/functional.md @@ -522,6 +522,30 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo --- +### F-14: Job-queue inzicht en beheer (`/jobs`) + +**Prioriteit:** v1 — Operationele controle +**Persona:** Lars + +**Omschrijving:** +De `/jobs`-pagina geeft een overzicht van alle `ClaudeJob`-records voor het actieve product. Vanuit de `JobDetailPane` kan de gebruiker een mislukte, geannuleerde of overgeslagen job opnieuw in de wachtrij zetten. + +**Acceptatiecriteria:** + +#### Mislukte job opnieuw starten + +- [ ] Een `ClaudeJob` in status `FAILED`, `CANCELLED` of `SKIPPED` toont een "Opnieuw starten"-knop in de `JobDetailPane`. +- [ ] De knop reset de bestaande job (geen nieuwe job aanmaken): `status → QUEUED`, `retry_count + 1`, alle run-velden gecleared. +- [ ] Bij `SPRINT_IMPLEMENTATION`-jobs worden alle bijbehorende `SprintTaskExecution`-rows in dezelfde transactie teruggezet naar `PENDING`. +- [ ] Tijdens de server-action is de knop disabled (loading-state). De UI updatet via SSE zonder handmatige refresh. +- [ ] Demo-sessies zien een `DemoTooltip` op de knop en kunnen niet restarten (drie-laagse policy: knop disabled + server action `session.isDemo`-check + HTTP 403). + +**Randgevallen:** +- Job is ondertussen al door een andere actie opnieuw gestart (race condition) → server-action controleert de huidige status vóór de update; als de status niet meer `FAILED/CANCELLED/SKIPPED` is, retourneert de action een foutmelding. +- Demo-token probeert via directe API-aanroep te restarten → 403 Forbidden. + +--- + ## Navigatiestructuur ```