actions: idea-job triggers + cancel (M12 T-497)

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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 19:49:27 +02:00
parent 5f410d3b10
commit 33cbb6c2f4
2 changed files with 348 additions and 2 deletions

View file

@ -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<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }; ideaLog: { create: ReturnType<typeof vi.fn> }; $transaction: ReturnType<typeof vi.fn> }
type MockIdea = {
idea: { create: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
ideaLog: { create: ReturnType<typeof vi.fn> }
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
claudeWorker: { count: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
$executeRaw: ReturnType<typeof vi.fn>
}
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({

View file

@ -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<number> {
return prisma.claudeWorker.count({
where: {
user_id: userId,
last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) },
},
})
}
async function getSession() {
return getIronSession<SessionData>(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<ActionResult<{ job_id: string }>> {
return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM)
}
export async function startMakePlanJobAction(id: string): Promise<ActionResult<{ job_id: string }>> {
return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM)
}
async function startIdeaJob(
id: string,
kind: ClaudeJobKind,
newStatus: IdeaStatus,
allowedFrom: IdeaStatus[],
): Promise<ActionResult<{ job_id: string }>> {
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<ActionResult> {
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