diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 8f55e55..6fe0197 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -16,7 +16,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, active_product_id: true }, + select: { username: true, email: true, active_product_id: true, min_quota_pct: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, @@ -72,6 +72,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod activeProduct={activeProduct} products={accessibleProducts} hasActiveSprint={hasActiveSprint} + minQuotaPct={user.min_quota_pct} />
diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index e514797..dbf820b 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -64,7 +64,17 @@ type WorkerPayload = { product_id?: string } -type NotifyPayload = EntityPayload | JobPayload | WorkerPayload +// M13: per-iteration quota-rapport van de worker. Geen product-scope — +// elke heartbeat geldt voor alle producten waar deze user toegang toe heeft. +type WorkerHeartbeatPayload = { + type: 'worker_heartbeat' + user_id: string + token_id: string + last_quota_pct: number + last_quota_check_at: string +} + +type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload function isJobPayload(p: NotifyPayload): p is JobPayload { return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') @@ -74,6 +84,10 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload { return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected') } +function isWorkerHeartbeatPayload(p: NotifyPayload): p is WorkerHeartbeatPayload { + return 'type' in p && p.type === 'worker_heartbeat' +} + function shouldEmit( payload: NotifyPayload, productId: string, @@ -90,6 +104,10 @@ function shouldEmit( return payload.user_id === userId } + if (isWorkerHeartbeatPayload(payload)) { + return payload.user_id === userId + } + // M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier. if (payload.entity === 'question') return false diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index bd93d68..42f0e2c 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -30,6 +30,7 @@ interface NavBarProps { activeProduct: { id: string; name: string } | null products: { id: string; name: string }[] hasActiveSprint: boolean + minQuotaPct: number } export function NavBar({ @@ -41,6 +42,7 @@ export function NavBar({ activeProduct, products, hasActiveSprint, + minQuotaPct, }: NavBarProps) { const pathname = usePathname() const router = useRouter() @@ -188,7 +190,7 @@ export function NavBar({ {/* Rechts: solo-status + notifications + account-menu */}
- +
diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx index e370540..28f9288 100644 --- a/components/solo/nav-status-indicators.tsx +++ b/components/solo/nav-status-indicators.tsx @@ -40,13 +40,28 @@ function RealtimeIndicator({ ) } -export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) { +export function SoloNavStatusIndicators({ + hasActiveProduct, + minQuotaPct, +}: { + hasActiveProduct: boolean + minQuotaPct: number +}) { const realtimeStatus = useSoloStore((s) => s.realtimeStatus) const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + const workerQuotaPct = useSoloStore((s) => s.workerQuotaPct) if (!hasActiveProduct) return null + // M13: stand-by als alle workers low quota hebben (workerQuotaPct geldt + // voor de laatste-rapporterende worker; bij N>1 workers is dit een + // benadering — server-side aggregate is een v2-verbetering). + const isStandby = + connectedWorkers > 0 && + workerQuotaPct !== null && + workerQuotaPct < minQuotaPct + return (
0 ? 'bg-status-done' : 'bg-muted-foreground/40' + isStandby + ? 'bg-warning' + : connectedWorkers > 0 + ? 'bg-status-done' + : 'bg-muted-foreground/40' )} /> - {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} + {isStandby ? ( + + + Stand-by ({workerQuotaPct}%)} + /> + + Worker wacht tot Anthropic-quota stijgt boven {minQuotaPct}% + + + + ) : connectedWorkers > 0 ? ( + 'Agent verbonden' + ) : ( + 'Geen agent' + )}
) diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 928dd80..cf93361 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -40,6 +40,7 @@ export function useSoloRealtime(productId: string | null) { const setWorkers = useSoloStore.getState().setWorkers const incrementWorkers = useSoloStore.getState().incrementWorkers const decrementWorkers = useSoloStore.getState().decrementWorkers + const setWorkerQuota = useSoloStore.getState().setWorkerQuota if (!productId) { // Geen actief product (gebruiker zit niet op /solo) — stream uit @@ -119,6 +120,15 @@ export function useSoloRealtime(productId: string | null) { } if (raw.type === 'worker_connected') { incrementWorkers(); return } if (raw.type === 'worker_disconnected') { decrementWorkers(); return } + if (raw.type === 'worker_heartbeat') { + const hb = raw as { + type: 'worker_heartbeat' + last_quota_pct: number + last_quota_check_at: string + } + setWorkerQuota(hb.last_quota_pct, hb.last_quota_check_at) + return + } return } const payload = raw as RealtimeEvent diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 592976e..5fa6e7c 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -64,6 +64,11 @@ interface SoloStore { claudeJobsByTaskId: Record 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 @@ -82,6 +87,7 @@ interface SoloStore { setWorkers: (count: number) => void incrementWorkers: () => void decrementWorkers: () => void + setWorkerQuota: (pct: number, checkAt: string) => void handleRealtimeEvent: (event: RealtimeEvent) => void } @@ -93,6 +99,8 @@ export const useSoloStore = create((set, get) => ({ showConnectingIndicator: false, claudeJobsByTaskId: {}, connectedWorkers: 0, + workerQuotaPct: null, + workerQuotaCheckAt: null, initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -145,7 +153,15 @@ export const useSoloStore = create((set, get) => ({ 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) })), + 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