M13: Claude job queue — 'Voer uit'-knop + worker presence (ST-1111) (#18)
* feat(ST-1111.1): add ClaudeJob model and state-machine enum Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.2): add ClaudeJob status API mappers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.7): add job status pill with spinner on solo task cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ST-1111.8): cover job-status mappers and enqueue/cancel actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(ST-1111.9): document Claude job queue architecture and agent flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.10a): add ClaudeWorker presence model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.10c): forward worker presence events on solo SSE + initial count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1cb5772edd
commit
73087e9705
18 changed files with 921 additions and 27 deletions
|
|
@ -262,7 +262,7 @@ docs(ST-XXX): document profile feature
|
|||
|
||||
Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) die de REST-API als native tools voor Claude Code aanbiedt. Schema's worden gedeeld via een git submodule (`vendor/scrum4me`), niet gedupliceerd.
|
||||
|
||||
### Tools beschikbaar in Claude Code (16)
|
||||
### Tools beschikbaar in Claude Code (18)
|
||||
|
||||
**Read / context:**
|
||||
- `mcp__scrum4me__health` — service + DB ping
|
||||
|
|
@ -285,6 +285,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
|
|||
- `mcp__scrum4me__list_open_questions` — eigen vragen, max 50, recente eerst
|
||||
- `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__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`.
|
||||
|
||||
### Prompt
|
||||
|
||||
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
|
||||
|
|
|
|||
165
__tests__/actions/claude-jobs.test.ts
Normal file
165
__tests__/actions/claude-jobs.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockFindFirstTask,
|
||||
mockFindFirstJob,
|
||||
mockCreateJob,
|
||||
mockUpdateJob,
|
||||
mockExecuteRaw,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstTask: vi.fn(),
|
||||
mockFindFirstJob: vi.fn(),
|
||||
mockCreateJob: vi.fn(),
|
||||
mockUpdateJob: vi.fn(),
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
task: { findFirst: mockFindFirstTask },
|
||||
claudeJob: {
|
||||
findFirst: mockFindFirstJob,
|
||||
create: mockCreateJob,
|
||||
update: mockUpdateJob,
|
||||
},
|
||||
$executeRaw: mockExecuteRaw,
|
||||
},
|
||||
}))
|
||||
|
||||
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
const TASK_ID = 'task-cuid-1'
|
||||
const JOB_ID = 'job-cuid-1'
|
||||
const PRODUCT_ID = 'product-cuid-1'
|
||||
|
||||
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
|
||||
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteRaw.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('enqueueClaudeJobAction', () => {
|
||||
it('happy path: creates job with QUEUED status', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue(null)
|
||||
mockCreateJob.mockResolvedValue({ id: JOB_ID })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, jobId: JOB_ID })
|
||||
expect(mockCreateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when task not found', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(null)
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Task niet gevonden' })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows new enqueue after terminal (DONE) job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue(null) // no active job
|
||||
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelClaudeJobAction', () => {
|
||||
it('happy path: cancels QUEUED job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
|
||||
mockUpdateJob.mockResolvedValue({})
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: JOB_ID },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('demo user is blocked', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when job not found (ownership check)', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(null)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Job niet gevonden' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cancelling terminal (DONE) job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cancelling FAILED job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
||||
})
|
||||
})
|
||||
43
__tests__/lib/job-status.test.ts
Normal file
43
__tests__/lib/job-status.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
jobStatusToApi,
|
||||
jobStatusFromApi,
|
||||
JOB_STATUS_API_VALUES,
|
||||
ACTIVE_JOB_STATUSES,
|
||||
} from '@/lib/job-status'
|
||||
|
||||
describe('job-status mappers', () => {
|
||||
it('round-trips every API value', () => {
|
||||
for (const api of JOB_STATUS_API_VALUES) {
|
||||
const db = jobStatusFromApi(api)
|
||||
expect(db).not.toBeNull()
|
||||
expect(jobStatusToApi(db!)).toBe(api)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(jobStatusFromApi('NOT_A_STATUS')).toBeNull()
|
||||
expect(jobStatusFromApi('')).toBeNull()
|
||||
expect(jobStatusFromApi('active')).toBeNull()
|
||||
})
|
||||
|
||||
it('is case-insensitive on the API side (accepts both upper and lower)', () => {
|
||||
expect(jobStatusFromApi('running')).toBe('RUNNING')
|
||||
expect(jobStatusFromApi('RUNNING')).toBe('RUNNING')
|
||||
expect(jobStatusFromApi('QUEUED')).toBe('QUEUED')
|
||||
})
|
||||
|
||||
it('maps all 6 DB statuses to API', () => {
|
||||
expect(jobStatusToApi('QUEUED')).toBe('queued')
|
||||
expect(jobStatusToApi('CLAIMED')).toBe('claimed')
|
||||
expect(jobStatusToApi('RUNNING')).toBe('running')
|
||||
expect(jobStatusToApi('DONE')).toBe('done')
|
||||
expect(jobStatusToApi('FAILED')).toBe('failed')
|
||||
expect(jobStatusToApi('CANCELLED')).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => {
|
||||
expect(ACTIVE_JOB_STATUSES).toEqual(expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']))
|
||||
expect(ACTIVE_JOB_STATUSES).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
97
actions/claude-jobs.ts
Normal file
97
actions/claude-jobs.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status'
|
||||
|
||||
type EnqueueResult =
|
||||
| { success: true; jobId: string }
|
||||
| { error: string; jobId?: string }
|
||||
|
||||
type CancelResult = { success: true } | { error: string }
|
||||
|
||||
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
if (!taskId) return { error: 'task_id is verplicht' }
|
||||
|
||||
// Resolve task + product access in one query
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
story: { product: productAccessFilter(session.userId) },
|
||||
},
|
||||
select: { id: true, story: { select: { product_id: true } } },
|
||||
})
|
||||
if (!task) return { error: 'Task niet gevonden' }
|
||||
|
||||
const productId = task.story.product_id
|
||||
|
||||
// Idempotency: weiger als er al een actieve job voor deze task bestaat
|
||||
const existing = await prisma.claudeJob.findFirst({
|
||||
where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
return { error: 'Er loopt al een agent voor deze task', jobId: existing.id }
|
||||
}
|
||||
|
||||
const job = await prisma.claudeJob.create({
|
||||
data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||
})
|
||||
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: taskId,
|
||||
user_id: session.userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, jobId: job.id }
|
||||
}
|
||||
|
||||
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
if (!jobId) return { error: 'job_id is verplicht' }
|
||||
|
||||
const job = await prisma.claudeJob.findFirst({
|
||||
where: { id: jobId, user_id: session.userId },
|
||||
select: { id: true, status: true, task_id: true, product_id: true },
|
||||
})
|
||||
if (!job) return { error: 'Job niet gevonden' }
|
||||
|
||||
if (!ACTIVE_JOB_STATUSES.includes(job.status)) {
|
||||
return { error: 'Alleen actieve jobs kunnen geannuleerd worden' }
|
||||
}
|
||||
|
||||
await prisma.claudeJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: 'CANCELLED', finished_at: new Date() },
|
||||
})
|
||||
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_status',
|
||||
job_id: jobId,
|
||||
task_id: job.task_id,
|
||||
user_id: session.userId,
|
||||
product_id: job.product_id,
|
||||
status: jobStatusToApi('CANCELLED'),
|
||||
})}::text)
|
||||
`
|
||||
|
||||
revalidatePath(`/products/${job.product_id}/solo`)
|
||||
return { success: true }
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ const CHANNEL = 'scrum4me_changes'
|
|||
const HEARTBEAT_MS = 25_000
|
||||
const HARD_CLOSE_MS = 240_000
|
||||
|
||||
interface NotifyPayload {
|
||||
type EntityPayload = {
|
||||
op: 'I' | 'U' | 'D'
|
||||
// M11 (ST-1101) voegt entity:'question' toe op hetzelfde scrum4me_changes-
|
||||
// kanaal; we filteren die hieronder weg zodat solo-clients geen
|
||||
|
|
@ -37,12 +37,49 @@ interface NotifyPayload {
|
|||
changed_fields?: string[]
|
||||
}
|
||||
|
||||
type JobPayload = {
|
||||
type: 'claude_job_enqueued' | 'claude_job_status'
|
||||
job_id: string
|
||||
task_id: string
|
||||
user_id: string
|
||||
product_id: string
|
||||
status: string
|
||||
branch?: string
|
||||
summary?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type WorkerPayload = {
|
||||
type: 'worker_connected' | 'worker_disconnected'
|
||||
user_id: string
|
||||
token_id: string
|
||||
product_id?: string
|
||||
}
|
||||
|
||||
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
|
||||
|
||||
function isJobPayload(p: NotifyPayload): p is JobPayload {
|
||||
return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status')
|
||||
}
|
||||
|
||||
function isWorkerPayload(p: NotifyPayload): p is WorkerPayload {
|
||||
return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected')
|
||||
}
|
||||
|
||||
function shouldEmit(
|
||||
payload: NotifyPayload,
|
||||
productId: string,
|
||||
activeSprintId: string | null,
|
||||
userId: string,
|
||||
): boolean {
|
||||
if (isJobPayload(payload)) {
|
||||
return payload.user_id === userId && payload.product_id === productId
|
||||
}
|
||||
|
||||
if (isWorkerPayload(payload)) {
|
||||
return payload.user_id === userId
|
||||
}
|
||||
|
||||
// M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier.
|
||||
if (payload.entity === 'question') return false
|
||||
|
||||
|
|
@ -159,6 +196,17 @@ export async function GET(request: NextRequest) {
|
|||
})}\n\n`,
|
||||
)
|
||||
|
||||
// Stuur initiële ClaudeJob-state zodat de UI synchroon is bij reconnect
|
||||
const activeJobs = await prisma_jobs_findActive(userId, productId)
|
||||
if (activeJobs.length > 0) {
|
||||
enqueue(`event: claude_jobs_initial\ndata: ${JSON.stringify(activeJobs)}\n\n`)
|
||||
}
|
||||
|
||||
// Stale workers opruimen + actieve count sturen
|
||||
await prisma_workers_cleanup()
|
||||
const workerCount = await prisma_workers_count(userId)
|
||||
enqueue(`event: workers_initial\ndata: ${JSON.stringify({ count: workerCount })}\n\n`)
|
||||
|
||||
// Heartbeat als SSE-comment — voorkomt proxy-timeouts
|
||||
heartbeatTimer = setInterval(() => {
|
||||
enqueue(`: heartbeat\n\n`)
|
||||
|
|
@ -186,8 +234,6 @@ export async function GET(request: NextRequest) {
|
|||
})
|
||||
}
|
||||
|
||||
// Lokaal helper — Prisma vermijden voor deze ene query om de pg-only flow
|
||||
// schoon te houden. Geeft de actieve sprint van een product, of null.
|
||||
async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> {
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
return prisma.sprint.findFirst({
|
||||
|
|
@ -196,3 +242,51 @@ async function prisma_sprint_findActive(productId: string): Promise<{ id: string
|
|||
orderBy: { created_at: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
async function prisma_jobs_findActive(userId: string, productId: string) {
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
const { jobStatusToApi } = await import('@/lib/job-status')
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const jobs = await prisma.claudeJob.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
product_id: productId,
|
||||
OR: [
|
||||
{ status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] } },
|
||||
{ status: { in: ['DONE', 'FAILED'] }, finished_at: { gte: today } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true, task_id: true, status: true, branch: true, summary: true, error: true,
|
||||
},
|
||||
orderBy: { created_at: 'asc' },
|
||||
})
|
||||
return jobs.map(j => ({
|
||||
job_id: j.id,
|
||||
task_id: j.task_id,
|
||||
status: jobStatusToApi(j.status),
|
||||
branch: j.branch ?? undefined,
|
||||
summary: j.summary ?? undefined,
|
||||
error: j.error ?? undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const WORKER_STALE_MS = 60_000
|
||||
|
||||
async function prisma_workers_cleanup() {
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
await prisma.claudeWorker.deleteMany({
|
||||
where: { last_seen_at: { lt: new Date(Date.now() - WORKER_STALE_MS) } },
|
||||
})
|
||||
}
|
||||
|
||||
async function prisma_workers_count(userId: string): Promise<number> {
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
return prisma.claudeWorker.count({
|
||||
where: {
|
||||
user_id: userId,
|
||||
last_seen_at: { gt: new Date(Date.now() - 15_000) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
21
components/shared/job-status.ts
Normal file
21
components/shared/job-status.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||
|
||||
export const JOB_STATUS_LABELS: Record<ClaudeJobStatusApi, string> = {
|
||||
queued: 'Wacht…',
|
||||
claimed: 'Geclaimd…',
|
||||
running: 'Bezig…',
|
||||
done: 'Klaar',
|
||||
failed: 'Mislukt',
|
||||
cancelled: 'Geannuleerd',
|
||||
}
|
||||
|
||||
export const JOB_STATUS_COLORS: Record<ClaudeJobStatusApi, string> = {
|
||||
queued: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||
claimed: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||
running: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||
done: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||
failed: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30',
|
||||
cancelled: 'bg-muted text-muted-foreground border-border',
|
||||
}
|
||||
|
||||
export const JOB_STATUS_ACTIVE = new Set<ClaudeJobStatusApi>(['queued', 'claimed', 'running'])
|
||||
|
|
@ -92,6 +92,7 @@ export function SoloBoard({
|
|||
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 [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
|
@ -192,6 +193,13 @@ export function SoloBoard({
|
|||
status={realtimeStatus}
|
||||
showConnectingIndicator={showConnectingIndicator}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-1">
|
||||
<span className={cn(
|
||||
'size-2 rounded-full',
|
||||
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
|
||||
)} />
|
||||
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
|
||||
</div>
|
||||
</div>
|
||||
{sprintGoal && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{sprintGoal}</p>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
import type React from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBadge } from '@/components/shared/code-badge'
|
||||
import { JOB_STATUS_LABELS, JOB_STATUS_COLORS, JOB_STATUS_ACTIVE } from '@/components/shared/job-status'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { SoloTask } from './solo-board'
|
||||
|
||||
const PRIORITY_BORDER: Record<number, string> = {
|
||||
|
|
@ -21,6 +24,7 @@ interface SoloTaskCardProps {
|
|||
}
|
||||
|
||||
export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
|
||||
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: task.id,
|
||||
disabled: isDemo,
|
||||
|
|
@ -51,10 +55,26 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
|
|||
<p className="text-sm text-foreground leading-snug flex-1">{task.title}</p>
|
||||
{task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
|
||||
{task.story_title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 mt-0.5">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
|
||||
{task.story_title}
|
||||
</p>
|
||||
{job && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0',
|
||||
JOB_STATUS_COLORS[job.status],
|
||||
)}
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
role="button"
|
||||
aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`}
|
||||
>
|
||||
{JOB_STATUS_ACTIVE.has(job.status) && <Loader2 className="animate-spin" size={8} />}
|
||||
{JOB_STATUS_LABELS[job.status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import Link from 'next/link'
|
|||
import { toast } from 'sonner'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SoloTask } from './solo-board'
|
||||
|
||||
|
|
@ -43,12 +46,34 @@ type SaveState = 'idle' | 'saving' | 'saved'
|
|||
|
||||
function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) {
|
||||
const { updatePlan } = useSoloStore()
|
||||
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
|
||||
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
|
||||
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [, startTransition] = useTransition()
|
||||
const [jobPending, startJobTransition] = useTransition()
|
||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const savedPlanRef = useRef(task.implementation_plan ?? '')
|
||||
|
||||
function handleEnqueue() {
|
||||
startJobTransition(async () => {
|
||||
const result = await enqueueClaudeJobAction(task.id)
|
||||
if ('error' in result) {
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success('Agent ingeschakeld')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!job) return
|
||||
startJobTransition(async () => {
|
||||
const result = await cancelClaudeJobAction(job.job_id)
|
||||
if ('error' in result) toast.error(result.error)
|
||||
})
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (isDemo || localPlan === savedPlanRef.current) return
|
||||
|
||||
|
|
@ -133,14 +158,61 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mx-4 -mb-4 flex items-center border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
||||
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
||||
<Link
|
||||
href={`/products/${productId}/sprint/planning`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
className="text-xs text-primary hover:underline mr-auto"
|
||||
onClick={onClose}
|
||||
>
|
||||
Open in Sprint Board ↗
|
||||
</Link>
|
||||
|
||||
{!isDemo && !job && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleEnqueue}
|
||||
disabled={jobPending || connectedWorkers === 0}
|
||||
>
|
||||
Voer uit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{connectedWorkers === 0 && (
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
Geen Claude Code-sessie verbonden. Start claude lokaal en zeg 'wacht op jobs'.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{job?.status === 'queued' && (
|
||||
<span className="text-xs text-muted-foreground">Wacht op agent…</span>
|
||||
)}
|
||||
|
||||
{(job?.status === 'claimed' || job?.status === 'running') && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">Bezig: {job.summary ?? '…'}</span>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleCancel} disabled={jobPending}>
|
||||
Annuleer
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job?.status === 'done' && (
|
||||
<span className="text-xs text-status-done">
|
||||
Klaar{job.branch ? ` — branch ${job.branch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{job?.status === 'failed' && (
|
||||
<span className="text-xs text-error">Mislukt: {job.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 488 KiB |
69
docs/plans/ST-1111-claude-job-trigger.md
Normal file
69
docs/plans/ST-1111-claude-job-trigger.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# ST-1111 — 'Voer uit'-knop met Claude Code job queue
|
||||
|
||||
**Story:** Als developer wil ik op het solo-scherm per task een 'Voer uit'-knop, zodat ik mijn lokale Claude Code-sessie kan inschakelen om de taak uit te voeren.
|
||||
|
||||
**Branch:** `feat/M13-claude-job-queue`
|
||||
|
||||
---
|
||||
|
||||
## Sub-tasks en commits
|
||||
|
||||
| Task | Commit |
|
||||
|---|---|
|
||||
| ST-1111.1 DB: ClaudeJob model + enum + migration | `5274e1e` |
|
||||
| ST-1111.2 API: ClaudeJob status mappers | `a1b1f69` |
|
||||
| ST-1111.3 Server actions: enqueue + cancel | `9d9fb4b` |
|
||||
| ST-1111.4 SSE: ClaudeJob events op solo-stream + initial state | `ece0aa9` |
|
||||
| ST-1111.5 MCP-tools (scrum4me-mcp repo — aparte PR) | — |
|
||||
| ST-1111.6 UI: 'Voer uit' + cancel in TaskDetailDialog | `b9c65eb` |
|
||||
| ST-1111.7 UI: status-pill op SoloTaskCard | `dace427` |
|
||||
| ST-1111.8 Tests: mappers + actions | `2c2a246` |
|
||||
| ST-1111.9 Docs | dit bestand |
|
||||
|
||||
---
|
||||
|
||||
## Architectuur
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
QUEUED → CLAIMED → RUNNING → DONE
|
||||
→ FAILED
|
||||
→ CANCELLED (cancel-knop of server action)
|
||||
CLAIMED → QUEUED (stale cleanup, >30min, via wait_for_job)
|
||||
```
|
||||
|
||||
### NOTIFY-pijplijn
|
||||
|
||||
Omdat `claude_jobs` geen row-trigger heeft (zoals `tasks` en `stories`), stuurt de **server action** zelf `pg_notify` via `prisma.$executeRaw`:
|
||||
|
||||
```ts
|
||||
await prisma.$executeRaw`SELECT pg_notify('scrum4me_changes', ${JSON.stringify(payload)}::text)`
|
||||
```
|
||||
|
||||
Voordeel: expliciete controle over het payload-shape (met `type` i.p.v. `entity`). Nadeel: MCP-tools in de `scrum4me-mcp`-repo moeten hun eigen NOTIFY-aanroep hebben bij `update_job_status`.
|
||||
|
||||
### SSE-routing
|
||||
|
||||
De bestaande `/api/realtime/solo`-route herkent nu twee payload-shapes:
|
||||
- `entity: 'task'|'story'` — bestaande trigger-events
|
||||
- `type: 'claude_job_enqueued'|'claude_job_status'` — nieuwe job-events
|
||||
|
||||
Job-events worden gefilterd op `user_id + product_id`. Bij connect stuurt de route een `claude_jobs_initial`-event met alle actieve + recente (vandaag) jobs.
|
||||
|
||||
### Idempotency
|
||||
|
||||
`enqueueClaudeJobAction` weigert als `claude_jobs WHERE task_id=X AND status IN (QUEUED, CLAIMED, RUNNING)` bestaat. De client ontvangt `{ error, jobId }` zodat de UI naar de actieve job kan linken in plaats van een nieuw venstertje te openen.
|
||||
|
||||
---
|
||||
|
||||
## Beslissingen
|
||||
|
||||
**Waarom geen DB-trigger voor NOTIFY?**
|
||||
De MCP-server claimt jobs via raw SQL (FOR UPDATE SKIP LOCKED); die schrijft ook direct naar de DB. Een trigger zou clean zijn, maar de MCP-tools moeten hoe dan ook hun eigen NOTIFY-payload bouwen voor `update_job_status`. Applicatie-NOTIFY houdt de payloads consistent en expliciet.
|
||||
|
||||
**Waarom `cancelled` verwijderd uit de store?**
|
||||
Geannuleerde jobs zijn terminaal; het pill-element zou "Geannuleerd" tonen tot de gebruiker een refresh doet. In plaats daarvan wist `handleJobEvent` de entry bij `status === 'cancelled'` zodat de kaart teruggaat naar de "Voer uit"-staat.
|
||||
|
||||
**Auto-clear DONE/FAILED?**
|
||||
Niet geïmplementeerd in v1. De pill blijft staan totdat de SSE-connectie herstart (refresh, tab-hidden+visible). Acceptabel voor de eerste iteratie.
|
||||
|
|
@ -1047,6 +1047,56 @@ Patroon:
|
|||
|
||||
**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && <span {...listeners} />}`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren.
|
||||
|
||||
---
|
||||
|
||||
## Claude job queue (M13 — ST-1111)
|
||||
|
||||
Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
QUEUED → CLAIMED → RUNNING → DONE
|
||||
→ FAILED
|
||||
→ CANCELLED (door user)
|
||||
CLAIMED → QUEUED (stale claim cleanup, >30min)
|
||||
```
|
||||
|
||||
### ClaudeJob model
|
||||
|
||||
```
|
||||
claude_jobs
|
||||
id, user_id, product_id, task_id
|
||||
status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED)
|
||||
claimed_by_token_id (FK → api_tokens, nullable)
|
||||
claimed_at, started_at, finished_at
|
||||
branch, summary, error
|
||||
@@index([user_id, status])
|
||||
@@index([task_id, status])
|
||||
@@index([status, claimed_at]) — voor stale-claim cleanup
|
||||
```
|
||||
|
||||
### NOTIFY/LISTEN flow
|
||||
|
||||
```
|
||||
UI klikt 'Voer uit'
|
||||
→ enqueueClaudeJobAction() Server Action
|
||||
→ prisma.claudeJob.create(QUEUED)
|
||||
→ prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...})
|
||||
→ /api/realtime/solo SSE server-side filter: user_id + product_id
|
||||
→ EventSource.onmessage browser: handleJobEvent()
|
||||
→ useSoloStore.claudeJobsByTaskId map
|
||||
→ SoloTaskCard pill + dialog-footer update
|
||||
```
|
||||
|
||||
### Idempotency
|
||||
|
||||
`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken.
|
||||
|
||||
### Hybride-ready
|
||||
|
||||
De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variabele | Doel | Waar te vinden |
|
||||
|
|
|
|||
32
lib/job-status.ts
Normal file
32
lib/job-status.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ClaudeJobStatus } from '@prisma/client'
|
||||
|
||||
const JOB_DB_TO_API = {
|
||||
QUEUED: 'queued',
|
||||
CLAIMED: 'claimed',
|
||||
RUNNING: 'running',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const satisfies Record<ClaudeJobStatus, string>
|
||||
|
||||
const JOB_API_TO_DB: Record<string, ClaudeJobStatus> = {
|
||||
queued: 'QUEUED',
|
||||
claimed: 'CLAIMED',
|
||||
running: 'RUNNING',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
cancelled: 'CANCELLED',
|
||||
}
|
||||
|
||||
export type ClaudeJobStatusApi = typeof JOB_DB_TO_API[ClaudeJobStatus]
|
||||
|
||||
export function jobStatusToApi(s: ClaudeJobStatus): ClaudeJobStatusApi {
|
||||
return JOB_DB_TO_API[s]
|
||||
}
|
||||
|
||||
export function jobStatusFromApi(s: string): ClaudeJobStatus | null {
|
||||
return JOB_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export const JOB_STATUS_API_VALUES = Object.values(JOB_DB_TO_API)
|
||||
export const ACTIVE_JOB_STATUSES: ClaudeJobStatus[] = ['QUEUED', 'CLAIMED', 'RUNNING']
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
|
||||
import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
|
||||
|
||||
const BACKOFF_START_MS = 1_000
|
||||
const BACKOFF_MAX_MS = 30_000
|
||||
|
|
@ -35,6 +35,11 @@ export function useSoloRealtime(productId: string | null) {
|
|||
useEffect(() => {
|
||||
const setStatus = useSoloStore.getState().setRealtimeStatus
|
||||
const handleEvent = useSoloStore.getState().handleRealtimeEvent
|
||||
const handleJobEvent = useSoloStore.getState().handleJobEvent
|
||||
const initJobs = useSoloStore.getState().initJobs
|
||||
const setWorkers = useSoloStore.getState().setWorkers
|
||||
const incrementWorkers = useSoloStore.getState().incrementWorkers
|
||||
const decrementWorkers = useSoloStore.getState().decrementWorkers
|
||||
|
||||
if (!productId) {
|
||||
// Geen actief product (gebruiker zit niet op /solo) — stream uit
|
||||
|
|
@ -84,10 +89,39 @@ export function useSoloRealtime(productId: string | null) {
|
|||
scheduleIndicator('open')
|
||||
})
|
||||
|
||||
source.addEventListener('claude_jobs_initial', (e) => {
|
||||
if (!e.data) return
|
||||
try {
|
||||
initJobs(JSON.parse(e.data) as JobState[])
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
})
|
||||
|
||||
source.addEventListener('workers_initial', (e) => {
|
||||
if (!e.data) return
|
||||
try {
|
||||
const { count } = JSON.parse(e.data) as { count: number }
|
||||
setWorkers(count)
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
})
|
||||
|
||||
source.onmessage = (e) => {
|
||||
if (!e.data) return
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as RealtimeEvent
|
||||
const raw = JSON.parse(e.data) as RealtimeEvent | ClaudeJobEvent | { type: string }
|
||||
if ('type' in raw) {
|
||||
if (raw.type === 'claude_job_enqueued' || raw.type === 'claude_job_status') {
|
||||
handleJobEvent(raw as ClaudeJobEvent)
|
||||
return
|
||||
}
|
||||
if (raw.type === 'worker_connected') { incrementWorkers(); return }
|
||||
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
|
||||
return
|
||||
}
|
||||
const payload = raw as RealtimeEvent
|
||||
// Animatie A: kanban-move animeren via View Transitions API. Voor
|
||||
// task UPDATE-events wrap'en we de store-update in een view
|
||||
// transition. flushSync forceert React om synchroon te renderen
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "ClaudeJobStatus" AS ENUM ('QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "claude_jobs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"product_id" TEXT NOT NULL,
|
||||
"task_id" TEXT NOT NULL,
|
||||
"status" "ClaudeJobStatus" NOT NULL DEFAULT 'QUEUED',
|
||||
"claimed_by_token_id" TEXT,
|
||||
"claimed_at" TIMESTAMP(3),
|
||||
"started_at" TIMESTAMP(3),
|
||||
"finished_at" TIMESTAMP(3),
|
||||
"branch" TEXT,
|
||||
"summary" TEXT,
|
||||
"error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "claude_jobs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "claude_jobs_user_id_status_idx" ON "claude_jobs"("user_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "claude_jobs_task_id_status_idx" ON "claude_jobs"("task_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "claude_jobs_status_claimed_at_idx" ON "claude_jobs"("status", "claimed_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_claimed_by_token_id_fkey" FOREIGN KEY ("claimed_by_token_id") REFERENCES "api_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "claude_workers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_id" TEXT NOT NULL,
|
||||
"product_id" TEXT,
|
||||
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "claude_workers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "claude_workers_user_id_last_seen_at_idx" ON "claude_workers"("user_id", "last_seen_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "claude_workers_token_id_key" ON "claude_workers"("token_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_workers" ADD CONSTRAINT "claude_workers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_workers" ADD CONSTRAINT "claude_workers_token_id_fkey" FOREIGN KEY ("token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -29,6 +29,15 @@ enum PbiStatus {
|
|||
DONE
|
||||
}
|
||||
|
||||
enum ClaudeJobStatus {
|
||||
QUEUED
|
||||
CLAIMED
|
||||
RUNNING
|
||||
DONE
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
TO_DO
|
||||
IN_PROGRESS
|
||||
|
|
@ -66,14 +75,16 @@ model User {
|
|||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
api_tokens ApiToken[]
|
||||
products Product[]
|
||||
todos Todo[]
|
||||
product_members ProductMember[]
|
||||
assigned_stories Story[] @relation("StoryAssignee")
|
||||
login_pairings LoginPairing[]
|
||||
api_tokens ApiToken[]
|
||||
products Product[]
|
||||
todos Todo[]
|
||||
product_members ProductMember[]
|
||||
assigned_stories Story[] @relation("StoryAssignee")
|
||||
login_pairings LoginPairing[]
|
||||
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
|
||||
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
||||
claude_jobs ClaudeJob[]
|
||||
claude_workers ClaudeWorker[]
|
||||
|
||||
@@index([active_product_id])
|
||||
@@map("users")
|
||||
|
|
@ -90,13 +101,15 @@ model UserRole {
|
|||
}
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
token_hash String @unique
|
||||
label String?
|
||||
created_at DateTime @default(now())
|
||||
revoked_at DateTime?
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
token_hash String @unique
|
||||
label String?
|
||||
created_at DateTime @default(now())
|
||||
revoked_at DateTime?
|
||||
claimed_jobs ClaudeJob[]
|
||||
claude_worker ClaudeWorker?
|
||||
|
||||
@@index([token_hash])
|
||||
@@map("api_tokens")
|
||||
|
|
@ -119,8 +132,9 @@ model Product {
|
|||
stories Story[]
|
||||
todos Todo[]
|
||||
members ProductMember[]
|
||||
active_for_users User[] @relation("UserActiveProduct")
|
||||
active_for_users User[] @relation("UserActiveProduct")
|
||||
claude_questions ClaudeQuestion[]
|
||||
claude_jobs ClaudeJob[]
|
||||
|
||||
@@unique([user_id, name])
|
||||
@@unique([user_id, code])
|
||||
|
|
@ -225,12 +239,54 @@ model Task {
|
|||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
claude_questions ClaudeQuestion[]
|
||||
claude_jobs ClaudeJob[]
|
||||
|
||||
@@index([story_id, priority, sort_order])
|
||||
@@index([sprint_id, status])
|
||||
@@map("tasks")
|
||||
}
|
||||
|
||||
model ClaudeJob {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String
|
||||
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
||||
task_id String
|
||||
status ClaudeJobStatus @default(QUEUED)
|
||||
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
||||
claimed_by_token_id String?
|
||||
claimed_at DateTime?
|
||||
started_at DateTime?
|
||||
finished_at DateTime?
|
||||
branch String?
|
||||
summary String?
|
||||
error String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@index([user_id, status])
|
||||
@@index([task_id, status])
|
||||
@@index([status, claimed_at])
|
||||
@@map("claude_jobs")
|
||||
}
|
||||
|
||||
model ClaudeWorker {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
||||
token_id String
|
||||
product_id String?
|
||||
started_at DateTime @default(now())
|
||||
last_seen_at DateTime @default(now())
|
||||
|
||||
@@unique([token_id])
|
||||
@@index([user_id, last_seen_at])
|
||||
@@map("claude_workers")
|
||||
}
|
||||
|
||||
model ProductMember {
|
||||
id String @id @default(cuid())
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
import { create } from 'zustand'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||
|
||||
type TaskStatus = SoloTask['status']
|
||||
|
||||
export interface JobState {
|
||||
job_id: string
|
||||
task_id: string
|
||||
status: ClaudeJobStatusApi
|
||||
branch?: string
|
||||
summary?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ClaudeJobEvent =
|
||||
| { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' }
|
||||
| { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; summary?: string; error?: string }
|
||||
|
||||
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
|
||||
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
|
||||
// /api/realtime/solo (ST-802).
|
||||
|
|
@ -42,6 +56,9 @@ interface SoloStore {
|
|||
realtimeStatus: RealtimeStatus
|
||||
showConnectingIndicator: boolean
|
||||
|
||||
claudeJobsByTaskId: Record<string, JobState>
|
||||
connectedWorkers: number
|
||||
|
||||
initTasks: (tasks: SoloTask[]) => void
|
||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||
|
|
@ -52,6 +69,13 @@ interface SoloStore {
|
|||
|
||||
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
|
||||
|
||||
initJobs: (jobs: JobState[]) => void
|
||||
handleJobEvent: (event: ClaudeJobEvent) => void
|
||||
|
||||
setWorkers: (count: number) => void
|
||||
incrementWorkers: () => void
|
||||
decrementWorkers: () => void
|
||||
|
||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +84,8 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
pendingOps: new Set<string>(),
|
||||
realtimeStatus: 'connecting',
|
||||
showConnectingIndicator: false,
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
|
||||
initTasks: (tasks) =>
|
||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||
|
|
@ -101,6 +127,43 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
return { realtimeStatus: status, showConnectingIndicator }
|
||||
}),
|
||||
|
||||
initJobs: (jobs) =>
|
||||
set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }),
|
||||
|
||||
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
|
||||
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
|
||||
decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })),
|
||||
|
||||
handleJobEvent: (event) => {
|
||||
const { job_id, task_id } = event
|
||||
if (event.type === 'claude_job_enqueued') {
|
||||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status: 'queued' },
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (event.type === 'claude_job_status') {
|
||||
const { status, branch, summary, error } = event
|
||||
if (status === 'cancelled') {
|
||||
set((s) => {
|
||||
const next = { ...s.claudeJobsByTaskId }
|
||||
delete next[task_id]
|
||||
return { claudeJobsByTaskId: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status, branch, summary, error },
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
handleRealtimeEvent: (event) => {
|
||||
if (event.entity === 'task') {
|
||||
const { id, op } = event
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue