From 33cbb6c2f451762436ba829c59499c47c3f4891e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:49:27 +0200 Subject: [PATCH] actions: idea-job triggers + cancel (M12 T-497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/ideas.ts: - startGrillJobAction(id) — DRAFT/GRILLED/GRILL_FAILED/PLAN_READY → GRILLING; validates product+repo_url, idempotency check (active job 409), worker-count check (15s freshness), atomic $transaction creates ClaudeJob + flips idea.status + IdeaLog{JOB_EVENT}, manual pg_notify - startMakePlanJobAction(id) — GRILLED/PLAN_FAILED/PLAN_READY → PLANNING; same shape via shared startIdeaJob helper - cancelIdeaJobAction(id) — finds active QUEUED|CLAIMED|RUNNING job for idea, reverts status: grill→DRAFT/GRILLED based on grill_md presence; plan→GRILLED/PLAN_READY based on plan_md presence Tests: 31 cases incl. happy path, demo-403, no-product/no-repo-422, no-worker-422, idempotency-409, status-mismatch-422, cancel revert paths, 404 no-active-job. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/ideas-crud.test.ts | 153 ++++++++++++++++++++- actions/ideas.ts | 197 ++++++++++++++++++++++++++- 2 files changed, 348 insertions(+), 2 deletions(-) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index 6ceba0e..543b42c 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -24,7 +24,16 @@ vi.mock('@/lib/prisma', () => ({ delete: vi.fn(), }, ideaLog: { create: vi.fn() }, + claudeJob: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + claudeWorker: { + count: vi.fn(), + }, $transaction: vi.fn(), + $executeRaw: vi.fn().mockResolvedValue(0), }, })) @@ -37,9 +46,19 @@ import { updateGrillMdAction, updatePlanMdAction, downloadIdeaMdAction, + startGrillJobAction, + startMakePlanJobAction, + cancelIdeaJobAction, } from '@/actions/ideas' -type MockIdea = { idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType }; ideaLog: { create: ReturnType }; $transaction: ReturnType } +type MockIdea = { + idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType } + ideaLog: { create: ReturnType } + claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } + claudeWorker: { count: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} const m = prisma as unknown as MockIdea beforeEach(() => { @@ -207,6 +226,138 @@ body }) }) +describe('startGrillJobAction', () => { + const idea = { + id: 'idea-1', + status: 'DRAFT', + product_id: 'prod-1', + product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, + } + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue(idea) + m.claudeJob.findFirst.mockResolvedValue(null) + m.claudeWorker.count.mockResolvedValue(1) + m.claudeJob.create.mockResolvedValue({ id: 'job-1' }) + }) + + it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => { + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } }) + expect(m.$executeRaw).toHaveBeenCalled() + }) + + it('blocks demo-user', async () => { + mockSession.isDemo = true + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 403 }) + expect(m.claudeJob.create).not.toHaveBeenCalled() + }) + + it('blocks when product has no repo_url', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + ...idea, + product: { id: 'prod-1', repo_url: null }, + }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) }) + }) + + it('blocks when no idea is unlinked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) + + it('blocks when no worker is active', async () => { + m.claudeWorker.count.mockResolvedValueOnce(0) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) }) + expect(m.claudeJob.create).not.toHaveBeenCalled() + }) + + it('blocks when an active job already exists (409)', async () => { + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 409 }) + }) + + it('blocks invalid status (PLANNING)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('startMakePlanJobAction', () => { + const idea = { + id: 'idea-1', + status: 'GRILLED', + product_id: 'prod-1', + product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, + } + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue(idea) + m.claudeJob.findFirst.mockResolvedValue(null) + m.claudeWorker.count.mockResolvedValue(1) + m.claudeJob.create.mockResolvedValue({ id: 'job-2' }) + }) + + it('happy: GRILLED → PLANNING', async () => { + const r = await startMakePlanJobAction('idea-1') + expect(r).toMatchObject({ success: true }) + }) + + it('blocks from DRAFT (must grill first)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' }) + const r = await startMakePlanJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('cancelIdeaJobAction', () => { + it('grill cancel without prior grill_md → DRAFT', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLING', + grill_md: null, + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) + + const r = await cancelIdeaJobAction('idea-1') + expect(r).toEqual({ success: true }) + // Verify $transaction was called with 3 ops (job-update, idea-update, log) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('grill re-grill cancel with prior grill_md → GRILLED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLING', + grill_md: '# old grill', + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) + + const r = await cancelIdeaJobAction('idea-1') + expect(r).toEqual({ success: true }) + }) + + it('returns 404 when no active job', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLED', + grill_md: null, + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce(null) + const r = await cancelIdeaJobAction('idea-1') + expect(r).toMatchObject({ code: 404 }) + }) +}) + describe('downloadIdeaMdAction', () => { it('returns grill_md when present', async () => { m.idea.findFirst.mockResolvedValueOnce({ diff --git a/actions/ideas.ts b/actions/ideas.ts index ec05f74..9d1438b 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -17,8 +17,20 @@ import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea' import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } from '@/lib/idea-status' import { nextIdeaCode } from '@/lib/idea-code-server' import { parsePlanMd } from '@/lib/idea-plan-parser' +import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' -import type { Idea } from '@prisma/client' +import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client' + +// Worker-presence: aligned met /api/realtime/solo. +const WORKER_FRESH_MS = 15_000 +async function countActiveWorkers(userId: string): Promise { + return prisma.claudeWorker.count({ + where: { + user_id: userId, + last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) }, + }, + }) +} async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -264,6 +276,189 @@ export async function downloadIdeaMdAction( } } +// --------------------------------------------------------------------------- +// Job-triggers (Grill Me / Make Plan / Cancel) + +const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY'] +const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY'] + +export async function startGrillJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM) +} + +export async function startMakePlanJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM) +} + +async function startIdeaJob( + id: string, + kind: ClaudeJobKind, + newStatus: IdeaStatus, + allowedFrom: IdeaStatus[], +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('start-idea-job', session.userId) + if (limited) return limited + + // Laad idee + product (voor repo_url-validatie) + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { + id: true, + status: true, + product_id: true, + product: { select: { id: true, repo_url: true } }, + }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!allowedFrom.includes(idea.status)) { + return { + error: `Actie niet toegestaan in status ${idea.status}`, + code: 422, + } + } + if (!canTransition(idea.status, newStatus)) { + return { error: `Status-transitie ${idea.status}→${newStatus} ongeldig`, code: 422 } + } + + // Product-met-repo verplicht (M12 grill-keuze 3) + if (!idea.product_id || !idea.product?.repo_url) { + return { + error: 'Idee moet gekoppeld zijn aan een product met repo_url voordat je dit kunt starten.', + code: 422, + } + } + + // Idempotency: weiger als er al een actieve job loopt voor dit idee. + const existing = await prisma.claudeJob.findFirst({ + where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, + select: { id: true }, + }) + if (existing) { + return { + error: 'Er loopt al een actieve agent voor dit idee.', + code: 409, + details: { job_id: existing.id }, + } + } + + // Worker-presence — server-side check, naast UI-side disabled-rule. + const workers = await countActiveWorkers(session.userId) + if (workers === 0) { + return { + error: 'Geen Claude-worker actief. Start een lokale wait_for_job-loop en probeer opnieuw.', + code: 422, + } + } + + // Atomic: create job + flip idea-status + log. + const job = await prisma.$transaction(async (tx) => { + const j = await tx.claudeJob.create({ + data: { + user_id: session.userId, + product_id: idea.product_id!, + idea_id: id, + kind, + status: 'QUEUED', + }, + select: { id: true }, + }) + await tx.idea.update({ where: { id }, data: { status: newStatus } }) + await tx.ideaLog.create({ + data: { + idea_id: id, + type: 'JOB_EVENT', + content: `${kind} queued`, + metadata: { job_id: j.id, kind }, + }, + }) + return j + }) + + // Manual pg_notify zoals enqueueClaudeJobAction in actions/claude-jobs.ts. + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + idea_id: id, + user_id: session.userId, + product_id: idea.product_id, + kind, + status: 'queued', + })}::text) + ` + + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true, data: { job_id: job.id } } +} + +export async function cancelIdeaJobAction(id: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, grill_md: true, plan_md: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + + // Vind de actieve job — meest recente in QUEUED|CLAIMED|RUNNING. + const job = await prisma.claudeJob.findFirst({ + where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, + orderBy: { created_at: 'desc' }, + select: { id: true, kind: true }, + }) + if (!job) return { error: 'Geen actieve job om te annuleren', code: 404 } + + // Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er + // al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al + // plan_md was (re-plan-cancel), anders GRILLED. + let revertStatus: IdeaStatus + if (job.kind === 'IDEA_GRILL') { + revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT' + } else if (job.kind === 'IDEA_MAKE_PLAN') { + revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED' + } else { + return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 } + } + + await prisma.$transaction([ + prisma.claudeJob.update({ + where: { id: job.id }, + data: { status: 'CANCELLED', finished_at: new Date(), error: 'user_cancelled' }, + }), + prisma.idea.update({ where: { id }, data: { status: revertStatus } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'JOB_EVENT', + content: `${job.kind} cancelled by user`, + metadata: { job_id: job.id, revert_status: revertStatus }, + }, + }), + ]) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: job.id, + idea_id: id, + user_id: session.userId, + kind: job.kind, + status: 'cancelled', + })}::text) + ` + + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true } +} + // --------------------------------------------------------------------------- // Helpers