Scrum4Me/stores/solo-store.ts
Janpeter Visser 31dc429b61
feat(M13 PBI-31 T-519b/T-520b): NavBar stand-by badge + quota-check runbook (#119)
* feat(M13 T-519b): SSE worker_heartbeat + NavBar stand-by badge

Aanvulling op scrum4me-mcp PR #25 (worker_heartbeat MCP-tool).

- app/api/realtime/solo/route.ts: WorkerHeartbeatPayload type +
  isWorkerHeartbeatPayload guard + shouldEmit-routing op user_id.
- stores/solo-store.ts: workerQuotaPct + workerQuotaCheckAt state +
  setWorkerQuota action. Reset bij decrementWorkers naar 0.
- lib/realtime/use-solo-realtime.ts: handle worker_heartbeat-event,
  roep setWorkerQuota.
- components/solo/nav-status-indicators.tsx: stand-by badge wanneer
  workerQuotaPct < minQuotaPct + tooltip met drempel.
- components/shared/nav-bar.tsx + app/(app)/layout.tsx: minQuotaPct
  prop plumbing van User.min_quota_pct naar NavStatusIndicators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(M13 T-520b): pre-flight quota-check sectie in mcp-integration

Documenteert de batch-loop-uitbreiding:
1. get_worker_settings → min_quota_pct
2. bin/worker-quota-probe.sh → pct + reset
3. worker_heartbeat naar server (NavBar stand-by-badge)
4. Sleep tot reset bij low quota; anders wait_for_job

Verwijst naar bin/worker-quota-probe.sh in scrum4me-docker (zie
PR daar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:34:48 +02:00

283 lines
9.5 KiB
TypeScript

import { create } from 'zustand'
import type { SoloTask } from '@/components/solo/solo-board'
import type { ClaudeJobStatusApi } from '@/lib/job-status'
type TaskStatus = SoloTask['status']
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
export interface JobState {
job_id: string
task_id: string
status: ClaudeJobStatusApi
branch?: string
pushed_at?: string | null
pr_url?: string | null
verify_result?: VerifyResultApi | null
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; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; 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).
export interface RealtimeEvent {
op: 'I' | 'U' | 'D'
entity: 'task' | 'story'
id: string
story_id?: string
product_id: string
sprint_id: string | null
assignee_id: string | null
// Task-specifieke velden (alleen aanwezig als entity === 'task')
task_status?: TaskStatus
task_sort_order?: number
task_title?: string
// Story-specifieke velden (alleen aanwezig als entity === 'story')
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
story_sort_order?: number
story_title?: string
story_code?: string | null
// Op UPDATE: lijst van kolommen die zijn veranderd
changed_fields?: string[]
}
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
interface SoloStore {
tasks: Record<string, SoloTask>
/** Task-ids die op dit moment een eigen optimistic write in de lucht hebben.
* Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet
* twee keer toegepast wordt of door een latere echo overschreven. */
pendingOps: Set<string>
/** Realtime-connection state, beheerd door useSoloRealtime in de
* (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en
* de hook niet direct in dezelfde subtree draait. */
realtimeStatus: RealtimeStatus
showConnectingIndicator: boolean
claudeJobsByTaskId: Record<string, JobState>
connectedWorkers: number
// M13: laatste quota-rapport van een actieve worker. null = geen
// worker actief of nog geen heartbeat met quota ontvangen.
workerQuotaPct: number | null
workerQuotaCheckAt: string | null
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string | null) => void
updateVerifyOnly: (taskId: string, value: boolean) => void
updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void
markPending: (taskId: string) => void
clearPending: (taskId: string) => void
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
initJobs: (jobs: JobState[]) => void
handleJobEvent: (event: ClaudeJobEvent) => void
setWorkers: (count: number) => void
incrementWorkers: () => void
decrementWorkers: () => void
setWorkerQuota: (pct: number, checkAt: string) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
export const useSoloStore = create<SoloStore>((set, get) => ({
tasks: {},
pendingOps: new Set<string>(),
realtimeStatus: 'connecting',
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
workerQuotaPct: null,
workerQuotaCheckAt: null,
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
optimisticMove: (taskId, toStatus) => {
const prev = get().tasks[taskId]?.status ?? null
if (!prev) return null
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } }))
return prev
},
rollback: (taskId, prevStatus) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })),
updatePlan: (taskId, plan) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
updateVerifyOnly: (taskId, value) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
updateVerifyRequired: (taskId, value) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })),
markPending: (taskId) =>
set((s) => {
if (s.pendingOps.has(taskId)) return s
const next = new Set(s.pendingOps)
next.add(taskId)
return { pendingOps: next }
}),
clearPending: (taskId) =>
set((s) => {
if (!s.pendingOps.has(taskId)) return s
const next = new Set(s.pendingOps)
next.delete(taskId)
return { pendingOps: next }
}),
setRealtimeStatus: (status, showConnectingIndicator) =>
set((s) => {
if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) {
return s
}
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),
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
// worker is niet meer actueel.
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
})),
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
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, pushed_at, pr_url, verify_result, 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, pushed_at, pr_url, verify_result, summary, error },
},
}))
}
},
handleRealtimeEvent: (event) => {
if (event.entity === 'task') {
const { id, op } = event
if (op === 'D') {
set((s) => {
if (!(id in s.tasks)) return s
const next = { ...s.tasks }
delete next[id]
return { tasks: next }
})
return
}
// INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken
// zonder story-context (story_title, story_code) renderen we niet
// — gebruiker ziet ze pas na een refresh. Acceptabel voor v1.
const existing = get().tasks[id]
if (!existing) return
if (get().pendingOps.has(id)) {
// Echo van een eigen optimistic move — laat de optimistic-state staan
return
}
const updates: Partial<SoloTask> = {}
if (event.task_status !== undefined && event.task_status !== existing.status) {
updates.status = event.task_status
}
if (
event.task_sort_order !== undefined &&
event.task_sort_order !== existing.sort_order
) {
updates.sort_order = event.task_sort_order
}
if (event.task_title !== undefined && event.task_title !== existing.title) {
updates.title = event.task_title
}
if (Object.keys(updates).length === 0) return
set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } }))
return
}
if (event.entity === 'story') {
const { id, op } = event
if (op === 'D') {
// Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende
// SoloTask-records uit de store.
set((s) => {
const next: Record<string, SoloTask> = {}
for (const [taskId, task] of Object.entries(s.tasks)) {
if (task.story_id !== id) next[taskId] = task
}
return { tasks: next }
})
return
}
const tasks = get().tasks
const affectedIds = Object.entries(tasks)
.filter(([, t]) => t.story_id === id)
.map(([taskId]) => taskId)
if (affectedIds.length === 0) return
const newTitle = event.story_title
const newCode = event.story_code ?? null
set((s) => {
const next = { ...s.tasks }
for (const taskId of affectedIds) {
const t = next[taskId]
const titleChanged = newTitle !== undefined && t.story_title !== newTitle
const codeChanged = newCode !== t.story_code
if (!titleChanged && !codeChanged) continue
next[taskId] = {
...t,
...(titleChanged && newTitle !== undefined && { story_title: newTitle }),
...(codeChanged && { story_code: newCode }),
}
}
return { tasks: next }
})
}
},
}))