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:
Janpeter Visser 2026-04-29 19:51:48 +02:00 committed by GitHub
parent 1cb5772edd
commit 73087e9705
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 921 additions and 27 deletions

View file

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