Compare commits
3 commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6936159d | ||
|
|
661601e833 | ||
|
|
3a73b4f1c9 |
15 changed files with 374 additions and 14 deletions
72
__tests__/actions/settings.test.ts
Normal file
72
__tests__/actions/settings.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({
|
||||
mockUserUpdate: vi.fn(),
|
||||
mockGetIronSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||
vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession }))
|
||||
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } }))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: { user: { update: mockUserUpdate } },
|
||||
}))
|
||||
|
||||
import { updateMinQuotaPctAction } from '@/actions/settings'
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
const SESSION_UNAUTH = { userId: undefined, isDemo: false }
|
||||
|
||||
describe('updateMinQuotaPctAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUserUpdate.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('returns error when not authenticated', async () => {
|
||||
mockGetIronSession.mockResolvedValue(SESSION_UNAUTH)
|
||||
const result = await updateMinQuotaPctAction(20)
|
||||
expect(result).toMatchObject({ error: expect.any(String) })
|
||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 error for demo session', async () => {
|
||||
mockGetIronSession.mockResolvedValue(SESSION_DEMO)
|
||||
const result = await updateMinQuotaPctAction(20)
|
||||
expect(result).toMatchObject({ status: 403 })
|
||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 422 error when value is 0 (below min)', async () => {
|
||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
||||
const result = await updateMinQuotaPctAction(0)
|
||||
expect(result).toMatchObject({ status: 422 })
|
||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 422 error when value is 101 (above max)', async () => {
|
||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
||||
const result = await updateMinQuotaPctAction(101)
|
||||
expect(result).toMatchObject({ status: 422 })
|
||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves valid value and returns success', async () => {
|
||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
||||
const result = await updateMinQuotaPctAction(35)
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { min_quota_pct: 35 },
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts boundary values 1 and 100', async () => {
|
||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
||||
await updateMinQuotaPctAction(1)
|
||||
await updateMinQuotaPctAction(100)
|
||||
expect(mockUserUpdate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
29
actions/settings.ts
Normal file
29
actions/settings.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { minQuotaPctSchema } from '@/lib/schemas/user'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
export async function updateMinQuotaPctAction(value: number) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', status: 403 }
|
||||
|
||||
const parsed = minQuotaPctSchema.safeParse(value)
|
||||
if (!parsed.success) return { error: 'Waarde moet tussen 1 en 100 liggen', status: 422 }
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.userId },
|
||||
data: { min_quota_pct: parsed.data },
|
||||
})
|
||||
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { RoleManager } from '@/components/settings/role-manager'
|
||||
import { LeaveProductButton } from '@/components/settings/leave-product-button'
|
||||
import { ProfileEditor } from '@/components/settings/profile-editor'
|
||||
import { MinQuotaEditor } from '@/components/settings/min-quota-editor'
|
||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||
import Link from 'next/link'
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ export default async function SettingsPage() {
|
|||
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true },
|
||||
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true, min_quota_pct: true },
|
||||
}),
|
||||
prisma.userRole.findMany({ where: { user_id: session.userId } }),
|
||||
prisma.product.findMany({
|
||||
|
|
@ -157,6 +158,19 @@ export default async function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">Worker-instellingen</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Drempelwaarden voor de Claude-worker.
|
||||
</p>
|
||||
</div>
|
||||
<MinQuotaEditor
|
||||
currentValue={user?.min_quota_pct ?? 20}
|
||||
isDemo={session.isDemo ?? false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-foreground">API Tokens</h2>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,15 @@ type WorkerPayload = {
|
|||
product_id?: string
|
||||
}
|
||||
|
||||
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
|
||||
type WorkerHeartbeatPayload = {
|
||||
type: 'worker_heartbeat'
|
||||
user_id: string
|
||||
token_id: string
|
||||
last_quota_pct: number | null
|
||||
is_low: boolean
|
||||
}
|
||||
|
||||
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 +82,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 +102,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
|
||||
|
||||
|
|
|
|||
59
components/settings/min-quota-editor.tsx
Normal file
59
components/settings/min-quota-editor.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { updateMinQuotaPctAction } from '@/actions/settings'
|
||||
|
||||
interface MinQuotaEditorProps {
|
||||
currentValue: number
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
export function MinQuotaEditor({ currentValue, isDemo }: MinQuotaEditorProps) {
|
||||
const [value, setValue] = useState(currentValue)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handleSave() {
|
||||
startTransition(async () => {
|
||||
const result = await updateMinQuotaPctAction(value)
|
||||
if ('error' in result) {
|
||||
toast.error(result.error as string)
|
||||
} else {
|
||||
toast.success('Instelling opgeslagen')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="min-quota-pct" className="text-sm font-medium text-foreground">
|
||||
Minimaal beschikbaar Claude-quota voordat de worker een job oppakt (%)
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Worker slaapt tot quota gereset is wanneer onder deze drempel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="min-quota-pct"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={e => setValue(Number(e.target.value))}
|
||||
disabled={isDemo || isPending}
|
||||
className="w-24 rounded border border-border bg-surface-container px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button onClick={handleSave} disabled={isDemo || isPending} size="sm">
|
||||
Opslaan
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -44,22 +44,42 @@ export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct
|
|||
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
|
||||
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
|
||||
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
|
||||
const lowQuotaTokenIds = useSoloStore((s) => s.lowQuotaTokenIds)
|
||||
|
||||
if (!hasActiveProduct) return null
|
||||
|
||||
const allStandby = connectedWorkers > 0 && lowQuotaTokenIds.size >= connectedWorkers
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<RealtimeIndicator
|
||||
status={realtimeStatus}
|
||||
showConnectingIndicator={showConnectingIndicator}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className={cn(
|
||||
'size-2 rounded-full',
|
||||
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
|
||||
)} />
|
||||
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground" />
|
||||
}>
|
||||
<span className={cn(
|
||||
'size-2 rounded-full',
|
||||
connectedWorkers === 0
|
||||
? 'bg-muted-foreground/40'
|
||||
: allStandby
|
||||
? 'bg-warning'
|
||||
: 'bg-status-done'
|
||||
)} />
|
||||
{connectedWorkers === 0
|
||||
? 'Geen agent'
|
||||
: allStandby
|
||||
? 'Worker stand-by'
|
||||
: 'Agent verbonden'}
|
||||
</TooltipTrigger>
|
||||
{allStandby && (
|
||||
<TooltipContent>Worker wacht op quota-reset</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Documentation Index
|
||||
|
||||
Auto-generated on 2026-05-05 from front-matter and headings.
|
||||
Auto-generated on 2026-05-06 from front-matter and headings.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
|
|
|
|||
|
|
@ -75,5 +75,27 @@ weert).
|
|||
Dit patroon (notification-channel via een bestaande pg_notify-stream) is
|
||||
herbruikbaar — zie `docs/patterns/claude-question-channel.md`.
|
||||
|
||||
## Worker quota-gate flow (M13)
|
||||
|
||||
De `worker_heartbeat` MCP-tool gebruikt hetzelfde `scrum4me_changes`-kanaal met een nieuw event-type `worker_heartbeat`:
|
||||
|
||||
```
|
||||
Worker scrum4me-mcp Postgres SSE-route NavBar
|
||||
| | | | |
|
||||
|--worker_heartbeat(15,low)-->| | | |
|
||||
| |--pg_notify('scrum4me_changes', {type:'worker_heartbeat',is_low:true})-->|
|
||||
| | | |--data:{...}---->|
|
||||
| | | | |--stand-by badge
|
||||
```
|
||||
|
||||
**Pre-flight loop** (vóór elke `wait_for_job`):
|
||||
1. `get_worker_settings` → `min_quota_pct`
|
||||
2. `bash scripts/worker-quota-probe.sh` → `{ pct, reset_at_iso }`
|
||||
3. `worker_heartbeat(last_quota_pct, last_quota_check_at, is_low = pct < min_quota_pct)`
|
||||
4. Als `is_low`: log "wachten tot quota-reset om HH:MM", slaap tot `reset_at_iso + 5s`, herhaal stap 2
|
||||
5. Anders: `wait_for_job` aanroepen
|
||||
|
||||
De solo-store houdt `lowQuotaTokenIds: Set<string>` bij. De NavBar toont een stand-by badge wanneer `lowQuotaTokenIds.size >= connectedWorkers && connectedWorkers > 0`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,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
|
||||
|
||||
**Worker-instellingen + quota (M13):**
|
||||
- `mcp__scrum4me__get_worker_settings` — geen args; retourneert `{ min_quota_pct }` afgeleid van het Bearer-token. Gebruik dit voor de pre-flight quota-check.
|
||||
- `mcp__scrum4me__worker_heartbeat` — optionele args: `last_quota_pct` (0-100), `last_quota_check_at` (ISO-8601), `is_low` (boolean). Slaat quota-state op in `ClaudeWorker` en triggert een `worker_heartbeat` SSE-event zodat de NavBar een stand-by badge kan tonen.
|
||||
|
||||
**Job queue — agent worker mode (M13):**
|
||||
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator:
|
||||
- `kind: 'TASK_IMPLEMENTATION'` (default) — payload met `implementation_plan`, `story`, `pbi`, `sprint`, `repo_url`
|
||||
|
|
@ -73,6 +77,53 @@ Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaa
|
|||
**Code koppelen aan app**
|
||||
- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`.
|
||||
|
||||
## Pre-flight quota-check
|
||||
|
||||
Vóór elke `wait_for_job` kan de worker controleren of er voldoende Claude-quota is.
|
||||
|
||||
**Helper-script:** `scripts/worker-quota-probe.sh` doet een minimale API-call (1 token) en geeft JSON terug:
|
||||
|
||||
```json
|
||||
{ "remaining": 180000, "limit": 200000, "pct": 90, "reset_at_iso": "2026-05-06T13:00:00Z" }
|
||||
```
|
||||
|
||||
**Stappenplan:**
|
||||
|
||||
1. Haal drempel op: `get_worker_settings` → `min_quota_pct` (bijv. 20).
|
||||
2. Probe quota:
|
||||
|
||||
```bash
|
||||
PROBE=$(bash scripts/worker-quota-probe.sh)
|
||||
PCT=$(echo "$PROBE" | grep -o '"pct":[0-9]*' | cut -d: -f2)
|
||||
RESET_AT=$(echo "$PROBE" | grep -o '"reset_at_iso":"[^"]*"' | cut -d'"' -f4)
|
||||
```
|
||||
|
||||
3. Vergelijk en meld via `worker_heartbeat`:
|
||||
|
||||
```
|
||||
worker_heartbeat(
|
||||
last_quota_pct=<PCT>,
|
||||
last_quota_check_at=<nu als ISO-8601>,
|
||||
is_low=<PCT < min_quota_pct>
|
||||
)
|
||||
```
|
||||
|
||||
4. Bij low quota: retry-strategie — slaap tot reset + 5s:
|
||||
|
||||
```bash
|
||||
if [ "$PCT" -lt "$MIN_QUOTA_PCT" ]; then
|
||||
RESET_S=$(date -d "$RESET_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$RESET_AT" +%s 2>/dev/null || echo 0)
|
||||
NOW_S=$(date +%s)
|
||||
SLEEP_S=$(( RESET_S - NOW_S + 5 ))
|
||||
echo "Wachten tot quota-reset om $(date -d "$RESET_AT" '+%H:%M' 2>/dev/null || echo "$RESET_AT")"
|
||||
[ "$SLEEP_S" -gt 0 ] && sleep "$SLEEP_S"
|
||||
fi
|
||||
```
|
||||
|
||||
5. Na de sleep: herhaal stap 2 (re-probe) voordat `wait_for_job` wordt aangeroepen.
|
||||
|
||||
**Bekende beperking:** elke probe kost ~1 token; bij 12x/uur is de overhead 12 tokens/uur — verwaarloosbaar ten opzichte van taak-gebruik.
|
||||
|
||||
## Prompt
|
||||
|
||||
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
|
||||
|
|
|
|||
|
|
@ -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 setWorkerLowQuota = useSoloStore.getState().setWorkerLowQuota
|
||||
|
||||
if (!productId) {
|
||||
// Geen actief product (gebruiker zit niet op /solo) — stream uit
|
||||
|
|
@ -118,7 +119,17 @@ export function useSoloRealtime(productId: string | null) {
|
|||
return
|
||||
}
|
||||
if (raw.type === 'worker_connected') { incrementWorkers(); return }
|
||||
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
|
||||
if (raw.type === 'worker_disconnected') {
|
||||
const disconnected = raw as { type: string; token_id?: string }
|
||||
if (disconnected.token_id) setWorkerLowQuota(disconnected.token_id, false)
|
||||
decrementWorkers()
|
||||
return
|
||||
}
|
||||
if (raw.type === 'worker_heartbeat') {
|
||||
const hb = raw as { type: string; token_id: string; is_low: boolean }
|
||||
setWorkerLowQuota(hb.token_id, hb.is_low)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
const payload = raw as RealtimeEvent
|
||||
|
|
|
|||
3
lib/schemas/user.ts
Normal file
3
lib/schemas/user.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const minQuotaPctSchema = z.number().int().min(1).max(100)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "claude_workers" ADD COLUMN "last_quota_check_at" TIMESTAMP(3),
|
||||
ADD COLUMN "last_quota_pct" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "min_quota_pct" INTEGER NOT NULL DEFAULT 20;
|
||||
|
|
@ -121,6 +121,7 @@ model User {
|
|||
active_product_id String?
|
||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||
idea_code_counter Int @default(0)
|
||||
min_quota_pct Int @default(20)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
|
|
@ -378,9 +379,11 @@ model ClaudeWorker {
|
|||
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())
|
||||
product_id String?
|
||||
started_at DateTime @default(now())
|
||||
last_seen_at DateTime @default(now())
|
||||
last_quota_pct Int?
|
||||
last_quota_check_at DateTime?
|
||||
|
||||
@@unique([token_id])
|
||||
@@index([user_id, last_seen_at])
|
||||
|
|
|
|||
44
scripts/worker-quota-probe.sh
Executable file
44
scripts/worker-quota-probe.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# worker-quota-probe.sh — Probe Claude API rate-limit headers en output JSON.
|
||||
#
|
||||
# Vereist: ANTHROPIC_API_KEY env-var.
|
||||
# Output: { "remaining": N, "limit": N, "pct": N, "reset_at_iso": "..." }
|
||||
# Exit 0 bij succes, exit 1 bij fout (geen API key, curl-fout, parse-fout).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
echo '{"error":"ANTHROPIC_API_KEY not set"}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEADERS_FILE=$(mktemp /tmp/quota-probe-headers.XXXXXX)
|
||||
trap 'rm -f "$HEADERS_FILE"' EXIT
|
||||
|
||||
# Minimale API-aanroep (1 token) om rate-limit-headers te lezen.
|
||||
HTTP_STATUS=$(curl -s -w "%{http_code}" -D "$HEADERS_FILE" -o /dev/null \
|
||||
-X POST https://api.anthropic.com/v1/messages \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"."}]}')
|
||||
|
||||
if [ "$HTTP_STATUS" -ge 500 ]; then
|
||||
echo "{\"error\":\"API returned HTTP $HTTP_STATUS\"}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse headers (case-insensitive grep)
|
||||
REMAINING=$(grep -i 'anthropic-ratelimit-tokens-remaining:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true)
|
||||
LIMIT=$(grep -i 'anthropic-ratelimit-tokens-limit:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true)
|
||||
RESET=$(grep -i 'anthropic-ratelimit-tokens-reset:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true)
|
||||
|
||||
if [ -z "$REMAINING" ] || [ -z "$LIMIT" ] || [ "$LIMIT" -eq 0 ] 2>/dev/null; then
|
||||
echo '{"error":"rate-limit headers not present in response"}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PCT=$(( REMAINING * 100 / LIMIT ))
|
||||
|
||||
printf '{"remaining":%s,"limit":%s,"pct":%s,"reset_at_iso":"%s"}\n' \
|
||||
"$REMAINING" "$LIMIT" "$PCT" "$RESET"
|
||||
|
|
@ -63,6 +63,7 @@ interface SoloStore {
|
|||
|
||||
claudeJobsByTaskId: Record<string, JobState>
|
||||
connectedWorkers: number
|
||||
lowQuotaTokenIds: Set<string>
|
||||
|
||||
initTasks: (tasks: SoloTask[]) => void
|
||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||
|
|
@ -82,6 +83,7 @@ interface SoloStore {
|
|||
setWorkers: (count: number) => void
|
||||
incrementWorkers: () => void
|
||||
decrementWorkers: () => void
|
||||
setWorkerLowQuota: (tokenId: string, isLow: boolean) => void
|
||||
|
||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||
}
|
||||
|
|
@ -93,6 +95,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
showConnectingIndicator: false,
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
lowQuotaTokenIds: new Set<string>(),
|
||||
|
||||
initTasks: (tasks) =>
|
||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||
|
|
@ -146,6 +149,13 @@ export const useSoloStore = create<SoloStore>((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) })),
|
||||
setWorkerLowQuota: (tokenId, isLow) =>
|
||||
set(s => {
|
||||
const next = new Set(s.lowQuotaTokenIds)
|
||||
if (isLow) next.add(tokenId)
|
||||
else next.delete(tokenId)
|
||||
return { lowQuotaTokenIds: next }
|
||||
}),
|
||||
|
||||
handleJobEvent: (event) => {
|
||||
const { job_id, task_id } = event
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue