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

@ -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