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:
parent
5f410d3b10
commit
33cbb6c2f4
2 changed files with 348 additions and 2 deletions
|
|
@ -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({
|
||||
|
|
|
|||
197
actions/ideas.ts
197
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<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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue