diff --git a/CLAUDE.md b/CLAUDE.md index f13941a..45952b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,11 +286,23 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag **Job queue — agent worker mode (M13):** -- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. +- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg. - `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. -**Code koppellen aan app** -- 'Pak de volgende job uit de Scrum4Me-queue' - geeft in claude_workers een record toe, tool wait_for_job +**Batch-loop (verplichte agent-flow):** + +Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop: + +1. `wait_for_job` aanroepen. +2. Job uitvoeren volgens het meegegeven `implementation_plan`. +3. `update_job_status('done'|'failed')` aanroepen. +4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen. +5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap. + +Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. + +**Code koppelen aan app** +- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — geeft in claude_workers een record en start de batch-loop hierboven. ### Prompt diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index fea9d8c..1da99ef 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -3,17 +3,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, mockFindFirstTask, + mockFindManyTask, + mockFindFirstProduct, + mockFindFirstSprint, mockFindFirstJob, mockCreateJob, mockUpdateJob, mockExecuteRaw, + mockTransaction, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockFindFirstTask: vi.fn(), + mockFindManyTask: vi.fn(), + mockFindFirstProduct: vi.fn(), + mockFindFirstSprint: vi.fn(), mockFindFirstJob: vi.fn(), mockCreateJob: vi.fn(), mockUpdateJob: vi.fn(), mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + mockTransaction: vi.fn(), })) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) @@ -24,17 +32,24 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/prisma', () => ({ prisma: { - task: { findFirst: mockFindFirstTask }, + task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, + product: { findFirst: mockFindFirstProduct }, + sprint: { findFirst: mockFindFirstSprint }, claudeJob: { findFirst: mockFindFirstJob, create: mockCreateJob, update: mockUpdateJob, }, $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, }, })) -import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' +import { + enqueueClaudeJobAction, + enqueueAllTodoJobsAction, + cancelClaudeJobAction, +} from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -108,6 +123,76 @@ describe('enqueueClaudeJobAction', () => { }) }) +describe('enqueueAllTodoJobsAction', () => { + it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }]) + mockTransaction.mockResolvedValue([ + { id: 'job-a', task_id: 'task-a' }, + { id: 'job-b', task_id: 'task-b' }, + ]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockFindManyTask).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'TO_DO', + story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId }, + }), + }) + ) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('returns count=0 when product has no active sprint', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockFindManyTask).not.toHaveBeenCalled() + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockTransaction).not.toHaveBeenCalled() + expect(mockExecuteRaw).not.toHaveBeenCalled() + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns error when product not accessible', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) +}) + describe('cancelClaudeJobAction', () => { it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index fa9a1e8..17c55d3 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -10,6 +10,10 @@ type EnqueueResult = | { success: true; jobId: string } | { error: string; jobId?: string } +type EnqueueAllResult = + | { success: true; count: number } + | { error: string } + type CancelResult = { success: true } | { error: string } export async function enqueueClaudeJobAction(taskId: string): Promise { @@ -59,6 +63,68 @@ export async function enqueueClaudeJobAction(taskId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!productId) return { error: 'product_id is verplicht' } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Geen toegang tot dit product' } + + const userId = session.userId + + // Match het scope dat de gebruiker op het Solo Paneel ziet: + // alleen TO_DO-taken in de actieve sprint, in stories die aan deze + // gebruiker zijn toegewezen. Anders queue je per ongeluk taken die + // niet in de huidige sprint zitten of aan iemand anders toebehoren. + const sprint = await prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (!sprint) return { success: true, count: 0 } + + const tasks = await prisma.task.findMany({ + where: { + status: 'TO_DO', + story: { sprint_id: sprint.id, assignee_id: userId }, + claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, + }, + select: { id: true }, + }) + + if (tasks.length === 0) return { success: true, count: 0 } + + const created = await prisma.$transaction( + tasks.map(t => + prisma.claudeJob.create({ + data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' }, + select: { id: true, task_id: true }, + }) + ) + ) + + for (const job of created) { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: job.task_id, + user_id: userId, + product_id: productId, + status: 'queued', + })}::text) + ` + } + + revalidatePath(`/products/${productId}/solo`) + return { success: true, count: created.length } +} + export async function cancelClaudeJobAction(jobId: string): Promise { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index fa41d4a..384828b 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -92,7 +92,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {children} - + diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 995aee2..980bcc0 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -105,7 +105,6 @@ export default async function SoloProductPage({ params }: Props) { return ( - {/* Rechts: notifications + account-menu */} + {/* Rechts: solo-status + notifications + account-menu */}
+
diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx new file mode 100644 index 0000000..e370540 --- /dev/null +++ b/components/solo/nav-status-indicators.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useSoloStore } from '@/stores/solo-store' +import type { RealtimeStatus } from '@/stores/solo-store' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +function RealtimeIndicator({ + status, + showConnectingIndicator, +}: { + status: RealtimeStatus + showConnectingIndicator: boolean +}) { + let color = 'bg-status-done' + let label = 'Live' + if (showConnectingIndicator) { + if (status === 'disconnected') { + color = 'bg-priority-critical' + label = 'Verbroken — opnieuw proberen…' + } else { + color = 'bg-muted-foreground' + label = 'Verbinden…' + } + } + return ( + + + + } + /> + {label} + + + ) +} + +export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) { + const realtimeStatus = useSoloStore((s) => s.realtimeStatus) + const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) + const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + + if (!hasActiveProduct) return null + + return ( +
+ +
+ 0 ? 'bg-status-done' : 'bg-muted-foreground/40' + )} /> + {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} +
+
+ ) +} diff --git a/components/solo/realtime-bridge.tsx b/components/solo/realtime-bridge.tsx index bd04f69..37cc14b 100644 --- a/components/solo/realtime-bridge.tsx +++ b/components/solo/realtime-bridge.tsx @@ -1,21 +1,17 @@ // SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding -// blijft staan over Server Action-refreshes van de Solo-page heen. +// blijft staan over Server Action-refreshes heen. // -// Leest het huidige product-id uit de URL (`/products/[id]/solo`). -// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream -// gesloten — geen onnodige verbinding open houden. +// Stream opent zodra er een actief product is (ongeacht het pad), zodat +// de Live-status-dot en worker-presence-indicator in de NavBar overal +// werken. Buiten /solo is de solo-store leeg en zijn task-events no-ops +// (zie stores/solo-store.ts handleRealtimeEvent), dus de stream gedraagt +// zich automatisch als lichte presence-stream tot SoloBoard mount. 'use client' -import { usePathname } from 'next/navigation' import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime' -const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/ - -export function SoloRealtimeBridge() { - const pathname = usePathname() - const match = pathname?.match(SOLO_PATH_RE) - const productId = match?.[1] ?? null +export function SoloRealtimeBridge({ productId }: { productId: string | null }) { useSoloRealtime(productId) return null } diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index fb50be8..83d220e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -7,54 +7,16 @@ import { } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeStatus } from '@/stores/solo-store' import { taskStatusToApi } from '@/lib/task-status' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' +import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { SplitPane } from '@/components/split-pane/split-pane' import { SoloColumn, type ColumnStatus } from './solo-column' import { SoloTaskCardOverlay } from './solo-task-card' import { TaskDetailDialog } from './task-detail-dialog' import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet' -// ST-805: kleine status-dot in de header — groen wanneer SSE-stream open -// is, grijs/rood pas zichtbaar als de connectie >4s niet open is (animatie B -// zit in useSoloRealtime). Default groen tijdens de eerste 4s zodat micro- -// disconnects geen flikker geven. -function RealtimeIndicator({ - status, - showConnectingIndicator, -}: { - status: RealtimeStatus - showConnectingIndicator: boolean -}) { - let color = 'bg-status-done' - let label = 'Live' - if (showConnectingIndicator) { - if (status === 'disconnected') { - color = 'bg-priority-critical' - label = 'Verbroken — opnieuw proberen…' - } else { - color = 'bg-muted-foreground' - label = 'Verbinden…' - } - } - return ( - - - - } - /> - {label} - - - ) -} - export interface SoloTask { id: string title: string @@ -71,7 +33,6 @@ export interface SoloTask { export interface SoloBoardProps { productId: string - productName: string sprintGoal: string tasks: SoloTask[] unassignedStories: UnassignedStory[] @@ -87,17 +48,16 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { } export function SoloBoard({ - productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, + productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, }: SoloBoardProps) { const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() - const realtimeStatus = useSoloStore((s) => s.realtimeStatus) - const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) - const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() + const [batchPending, startBatchTransition] = useTransition() const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { @@ -169,40 +129,40 @@ export function SoloBoard({ const activeTask = activeDragId ? tasks[activeDragId] : null - const columns = ( -
- {COLUMN_STATUSES.map(status => ( - setSelectedTask(t)} - /> - ))} -
- ) + const queueableCount = columnTasks.TO_DO.filter(t => { + const job = claudeJobsByTaskId[t.id] + return !job || (job.status !== 'queued' && job.status !== 'claimed' && job.status !== 'running') + }).length + + function handleStartAll() { + if (queueableCount === 0) return + startBatchTransition(async () => { + const result = await enqueueAllTodoJobsAction(productId) + 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`) + } + }) + } return (
-
-
-

{productName}

- -
- 0 ? 'bg-status-done' : 'bg-muted-foreground/40' - )} /> - {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} -
-
+
+ + + {sprintGoal && ( -

{sprintGoal}

+

{sprintGoal}

)}