Solo batch-enqueue: per-PBI volgorde + blocker-dialog (#65)

* feat(solo): orderBy taken per PBI-hiërarchie

Voeg pbi.priority en pbi.sort_order toe aan de task.findMany orderBy in de solo-page query zodat taken per PBI gegroepeerd worden vóór story- en task-volgorde.

* feat(solo): previewEnqueueAllAction met blocker-detectie

Voeg previewEnqueueAllAction toe aan actions/claude-jobs.ts: haalt taken op in PBI-volgorde, filtert actieve jobs, detecteert eerste blocker (REVIEW taak of BLOCKED PBI). Retourneert tasks[], blockerIndex en blockerReason. Tests: 7 nieuwe cases voor alle blocker-scenario's en demo-blokkering.

* feat(solo): enqueueClaudeJobsBatchAction met IDOR-check

Voeg enqueueClaudeJobsBatchAction toe: accepteert expliciete taskIds[], verifieert dat alle IDs bij de ingelogde gebruiker horen (IDOR-preventie), slaat taken met actieve jobs over (idempotent), en maakt jobs aan in transactie in opgegeven volgorde. 6 nieuwe tests.

* feat(solo): BatchEnqueueBlockerDialog component

Nieuw dialoogvenster dat gebruiker waarschuwt bij gedetecteerde blocker: toont blockerReason in NL, prefixCount taken vóór blokkade, confirm-knop (disabled met tooltip bij count=0) en annuleer-knop. 7 tests voor rendering, click-handlers en disabled-state.

* feat(solo): preview-then-confirm flow in SoloBoard Voer-alle-uit

Vervang directe enqueueAllTodoJobsAction door previewEnqueueAllAction + BatchEnqueueBlockerDialog. Geen blocker → enqueueClaudeJobsBatchAction direct. Wel blocker → dialog met prefix-enqueue of annuleer. Loading-state op knop tijdens preview en confirm. 5 integratie-tests.

* test(solo): uitgebreide batch-preflight tests met 2 PBI's en 4 taken

Nieuw claude-jobs-batch.test.ts: 10 gevallen voor previewEnqueueAllAction (PBI-volgorde, REVIEW/BLOCKED-detectie, active-job-skip met blockerIndex-shift) en enqueueClaudeJobsBatchAction (happy path, IDOR, active-job-skip, demo).
This commit is contained in:
Janpeter Visser 2026-05-03 13:55:13 +02:00 committed by GitHub
parent add275fa6d
commit 0ce6076a5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1059 additions and 4 deletions

View file

@ -8,7 +8,8 @@ import {
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import { taskStatusToApi } from '@/lib/task-status'
import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs'
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { SplitPane } from '@/components/split-pane/split-pane'
@ -61,6 +62,15 @@ export function SoloBoard({
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const [batchPending, startBatchTransition] = useTransition()
const [confirmPending, startConfirmTransition] = useTransition()
type BlockerDialogState = {
prefixCount: number
blockerReason: 'task-review' | 'pbi-blocked'
blockerLabel: string
prefixIds: string[]
}
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
@ -140,7 +150,42 @@ export function SoloBoard({
function handleStartAll() {
if (queueableCount === 0) return
startBatchTransition(async () => {
const result = await enqueueAllTodoJobsAction(productId)
const preview = await previewEnqueueAllAction(productId)
if ('error' in preview) {
toast.error(preview.error)
return
}
if (preview.blockerIndex === null) {
const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id)
const result = await enqueueClaudeJobsBatchAction(productId, todoIds)
if ('error' in result) {
toast.error(result.error)
} else if (result.count === 0) {
toast.info('Geen taken om te starten')
} else {
toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`)
}
} else {
const blockerTask = preview.tasks[preview.blockerIndex]
const blockerLabel = preview.blockerReason === 'task-review'
? `${blockerTask.story_title}${blockerTask.title}`
: blockerTask.story_title
setBlockerDialog({
prefixCount: preview.blockerIndex,
blockerReason: preview.blockerReason!,
blockerLabel,
prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id),
})
}
})
}
function handleBlockerConfirm() {
if (!blockerDialog) return
const { prefixIds } = blockerDialog
setBlockerDialog(null)
startConfirmTransition(async () => {
const result = await enqueueClaudeJobsBatchAction(productId, prefixIds)
if ('error' in result) {
toast.error(result.error)
} else if (result.count === 0) {
@ -159,9 +204,9 @@ export function SoloBoard({
<Button
size="sm"
onClick={handleStartAll}
disabled={isDemo || batchPending || queueableCount === 0}
disabled={isDemo || batchPending || confirmPending || queueableCount === 0}
>
{batchPending ? 'Starten…' : `Start agents (${queueableCount})`}
{batchPending || confirmPending ? 'Starten…' : `Start agents (${queueableCount})`}
</Button>
</DemoTooltip>
{sprintGoal && (
@ -234,6 +279,18 @@ export function SoloBoard({
onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
/>
{blockerDialog && (
<BatchEnqueueBlockerDialog
open
onOpenChange={(v) => { if (!v) setBlockerDialog(null) }}
prefixCount={blockerDialog.prefixCount}
blockerReason={blockerDialog.blockerReason}
blockerLabel={blockerDialog.blockerLabel}
onConfirm={handleBlockerConfirm}
onCancel={() => setBlockerDialog(null)}
/>
)}
</div>
)
}