From 001afa0d3e13693e827a6ee93947bfa85d10cd79 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 25 May 2026 22:19:53 -0700 Subject: [PATCH] feat: Claude usage threshold gate (per-user, 4 dispatch sites) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-user threshold gate over the existing Anthropic OAuth usage poller (agent/src/usage-poller.ts → hub/src/usage/store.ts) so remo-code pauses NEW dispatches when 5-hour or 7-day utilization crosses a user-configured cap. Architecture review: .planning/phases/claude-usage-thresholds/00-architecture-review.md Schema (idempotent, back-compat): - users.claude_session_threshold_pct INT NULL (1-100, NULL = OFF) - users.claude_week_threshold_pct INT NULL (1-100, NULL = OFF) - scheduled_task_runs.status += 'skipped_quota' (CHECK constraint rebuilt) Gate sites (all 4 dispatch entry points per review §3): 1. hub/src/scheduler/dispatcher.ts — fireTask() + queue-promotion path 2. hub/src/sessions/routing.ts — pickSessionTarget returns kind:'quota_blocked' 3. hub/src/error-capture/dispatcher.ts — dispatchPendingError head 4. hub/src/ws/client.ts — send_message handler (returns send_refused) Refusal shape (uniform across HTTP + WS): { error: 'quota_threshold_reached', reason: 'session_threshold'|'week_threshold', utilization_pct, threshold_pct, resets_at } Decision precedence: session_threshold wins ties (soonest reset). Opus carve-out counts toward week_threshold. NULL thresholds OR null snapshot = fail-open. API (plain Hono, JWT-authed): - GET /api/account/claude-thresholds → { session_pct, week_pct } - PUT /api/account/claude-thresholds body strict-validated, both nullable - GET /api/account/usage → { usage, thresholds, paused, reason, resets_at } UI: Settings → Profile → new ClaudeUsageCard. Two per-cap toggles + 50-95 sliders. Inline 'Paused' banner when current snapshot trips a saved threshold. Suggested default value is 90 but column stays NULL until the user clicks Save. In-flight runs are NOT killed — only NEW dispatch is refused. Waiter promotion in session-queue re-evaluates the gate and drops with skipped_quota if still over. Tests: - hub/test/threshold.test.ts (10 cases): back-compat null, fail-open, session-over, week-over, both-over precedence, boundary >=, opus carve-out, single-cap modes. - Full suite: 256 pass / 0 fail / 56 skip (e2e gated by REMO_E2E_DB_URL). Out of scope (deferred per review §1/§4): - ccusage fallback (the OAuth endpoint is authoritative; no new dep) - Per-agent usage endpoint (usage is per-user / per-subscription) - Token-counts hover (not in OAuth payload) --- hub/src/api/account.ts | 77 +++++++++ hub/src/api/sessions.ts | 10 ++ hub/src/db/dal.ts | 35 +++- hub/src/db/scheduled-tasks-dal.ts | 1 + hub/src/db/schema.sql | 28 ++++ hub/src/error-capture/dispatcher.ts | 19 +++ hub/src/scheduler/dispatcher.ts | 35 ++++ hub/src/scheduler/senders/triage.ts | 8 + hub/src/sessions/routing.ts | 22 +++ hub/src/usage/threshold.ts | 118 ++++++++++++++ hub/src/ws/client.ts | 21 +++ hub/test/threshold.test.ts | 111 +++++++++++++ web/src/components/ClaudeUsageCard.tsx | 216 +++++++++++++++++++++++++ web/src/components/SettingsPage.tsx | 3 + 14 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 hub/src/usage/threshold.ts create mode 100644 hub/test/threshold.test.ts create mode 100644 web/src/components/ClaudeUsageCard.tsx diff --git a/hub/src/api/account.ts b/hub/src/api/account.ts index cbdee5a..ff80b1c 100644 --- a/hub/src/api/account.ts +++ b/hub/src/api/account.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { z } from 'zod'; import { authMiddleware } from '../auth/middleware.ts'; import { getUserCoolifyWebhookStatus, @@ -7,7 +8,11 @@ import { getUserCoolifyWebhookAllowedIps, setUserCoolifyWebhookAllowedIps, listCoolifyWebhookAttempts, + getUserClaudeThresholds, + setUserClaudeThresholds, } from '../db/dal.ts'; +import { getUsage } from '../usage/store.ts'; +import { evaluateThreshold } from '../usage/threshold.ts'; export const accountRouter = new Hono(); export { accountRouter as account }; @@ -96,6 +101,78 @@ accountRouter.get('/coolify-webhook-allowed-ips', async (c) => { } }); +// ── Claude usage thresholds ────────────────────────────────────────────────── +// GET /api/account/claude-thresholds → { session_pct, week_pct } +accountRouter.get('/claude-thresholds', async (c) => { + const userId = c.get('userId') as string; + try { + const t = await getUserClaudeThresholds(userId); + return c.json({ + session_pct: t.claude_session_threshold_pct, + week_pct: t.claude_week_threshold_pct, + }); + } catch (err: any) { + console.error('[account] claude-thresholds GET failed:', err?.code, err?.message); + return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); + } +}); + +const ThresholdSchema = z.object({ + session_pct: z.number().int().min(1).max(100).nullable(), + week_pct: z.number().int().min(1).max(100).nullable(), +}).strict(); + +// PUT /api/account/claude-thresholds body: { session_pct, week_pct } +accountRouter.put('/claude-thresholds', async (c) => { + const userId = c.get('userId') as string; + let body: any; + try { body = await c.req.json(); } catch { return c.json({ error: 'bad_json' }, 400); } + const parsed = ThresholdSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', detail: parsed.error.flatten() }, 400); + } + try { + const saved = await setUserClaudeThresholds(userId, { + claude_session_threshold_pct: parsed.data.session_pct, + claude_week_threshold_pct: parsed.data.week_pct, + }); + return c.json({ + session_pct: saved.claude_session_threshold_pct, + week_pct: saved.claude_week_threshold_pct, + }); + } catch (err: any) { + console.error('[account] claude-thresholds PUT failed:', err?.code, err?.message); + return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); + } +}); + +// GET /api/account/usage → { usage, thresholds, paused, reason, ... } +// Used by Layout.tsx on first paint before the WS event arrives. +accountRouter.get('/usage', async (c) => { + const userId = c.get('userId') as string; + try { + const t = await getUserClaudeThresholds(userId); + const snap = getUsage(userId); + const decision = evaluateThreshold(snap, t); + return c.json({ + usage: snap?.usage ?? null, + updated_at: snap?.updated_at ?? null, + thresholds: { + session_pct: t.claude_session_threshold_pct, + week_pct: t.claude_week_threshold_pct, + }, + paused: !decision.allowed, + reason: decision.reason ?? null, + utilization_pct: decision.utilization_pct ?? null, + threshold_pct: decision.threshold_pct ?? null, + resets_at: decision.resets_at ?? null, + }); + } catch (err: any) { + console.error('[account] usage GET failed:', err?.code, err?.message); + return c.json({ error: 'internal_error', code: err?.code ?? null }, 500); + } +}); + // PUT /api/account/coolify-webhook-allowed-ips { allowed_ips: string } accountRouter.put('/coolify-webhook-allowed-ips', async (c) => { const userId = c.get('userId') as string; diff --git a/hub/src/api/sessions.ts b/hub/src/api/sessions.ts index ae73b89..eebe117 100644 --- a/hub/src/api/sessions.ts +++ b/hub/src/api/sessions.ts @@ -159,6 +159,16 @@ sessions.post('/heal', async (c) => { excludeSupervisorIds: Array.from(exclude), }) + if (pick.kind === 'quota_blocked') { + return c.json({ + error: 'quota_threshold_reached', + reason: pick.reason, + utilization_pct: pick.utilization_pct, + threshold_pct: pick.threshold_pct, + resets_at: pick.resets_at, + }, 503) + } + if (pick.kind === 'none') { return c.json({ error: 'no_target_available' }, 503) } diff --git a/hub/src/db/dal.ts b/hub/src/db/dal.ts index 810370b..e0b611d 100644 --- a/hub/src/db/dal.ts +++ b/hub/src/db/dal.ts @@ -276,7 +276,7 @@ export async function revokeAllUserCredentials(userId: string): Promise<{ revoke // ── Users / Profiles ────────────────────────────────────────────────────────── export async function getUserById(id: string) { - const rows = await sql`SELECT id, email, display_name, avatar_url, role, system_prompt, timezone, daily_cost_cap_usd, web_push_enabled, created_at, updated_at FROM users WHERE id = ${id}`; + const rows = await sql`SELECT id, email, display_name, avatar_url, role, system_prompt, timezone, daily_cost_cap_usd, web_push_enabled, claude_session_threshold_pct, claude_week_threshold_pct, created_at, updated_at FROM users WHERE id = ${id}`; return rows[0] ?? null; } @@ -290,6 +290,39 @@ export async function getUserSystemPrompt(id: string): Promise { return (rows[0]?.system_prompt as string | null) ?? null; } +// ── Claude usage thresholds ────────────────────────────────────────────────── +export interface ClaudeThresholds { + claude_session_threshold_pct: number | null; + claude_week_threshold_pct: number | null; +} + +export async function getUserClaudeThresholds(userId: string): Promise { + const rows = await sql` + SELECT claude_session_threshold_pct, claude_week_threshold_pct + FROM users WHERE id = ${userId} + `; + const row = rows[0]; + return { + claude_session_threshold_pct: row?.claude_session_threshold_pct ?? null, + claude_week_threshold_pct: row?.claude_week_threshold_pct ?? null, + }; +} + +export async function setUserClaudeThresholds( + userId: string, + thresholds: ClaudeThresholds, +): Promise { + const rows = await sql` + UPDATE users + SET claude_session_threshold_pct = ${thresholds.claude_session_threshold_pct}, + claude_week_threshold_pct = ${thresholds.claude_week_threshold_pct}, + updated_at = now() + WHERE id = ${userId} + RETURNING claude_session_threshold_pct, claude_week_threshold_pct + `; + return rows[0] ?? thresholds; +} + export type UserInstructions = { claude_global_md: string | null; codex_agents_md: string | null; diff --git a/hub/src/db/scheduled-tasks-dal.ts b/hub/src/db/scheduled-tasks-dal.ts index 7e9f997..4cfeda7 100644 --- a/hub/src/db/scheduled-tasks-dal.ts +++ b/hub/src/db/scheduled-tasks-dal.ts @@ -21,6 +21,7 @@ export type RunStatus = | 'success' | 'failed' | 'skipped' + | 'skipped_quota' | 'cancelled' export interface ScheduledTask { diff --git a/hub/src/db/schema.sql b/hub/src/db/schema.sql index 6e6c1df..9153808 100644 --- a/hub/src/db/schema.sql +++ b/hub/src/db/schema.sql @@ -492,3 +492,31 @@ CREATE TABLE IF NOT EXISTS coolify_webhook_attempts ( CREATE INDEX IF NOT EXISTS idx_coolify_webhook_attempts_user_recv ON coolify_webhook_attempts(user_id, received_at DESC); +-- ── feat/claude-usage-thresholds ───────────────────────────────────────────── +-- Per-user thresholds for the Anthropic OAuth usage gate. Compared against the +-- in-memory snapshot from agent/src/usage-poller.ts (utilization is already a +-- percentage 0-100). NULL = gate OFF (back-compat — existing users are not +-- silently opted in on deploy). +ALTER TABLE users ADD COLUMN IF NOT EXISTS claude_session_threshold_pct INTEGER; +ALTER TABLE users ADD COLUMN IF NOT EXISTS claude_week_threshold_pct INTEGER; + +DO $$ BEGIN + ALTER TABLE users ADD CONSTRAINT users_claude_session_threshold_pct_range + CHECK (claude_session_threshold_pct IS NULL + OR (claude_session_threshold_pct BETWEEN 1 AND 100)); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE users ADD CONSTRAINT users_claude_week_threshold_pct_range + CHECK (claude_week_threshold_pct IS NULL + OR (claude_week_threshold_pct BETWEEN 1 AND 100)); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- New run status: 'skipped_quota' — distinguishes threshold-gated skips from +-- daily-cost-cap skips so the run history drawer can filter them separately. +DO $$ BEGIN + ALTER TABLE scheduled_task_runs DROP CONSTRAINT IF EXISTS scheduled_task_runs_status_check; + ALTER TABLE scheduled_task_runs ADD CONSTRAINT scheduled_task_runs_status_check + CHECK (status IN ('running','success','failed','skipped','pending','in_flight','cancelled','skipped_quota')); +EXCEPTION WHEN others THEN NULL; END $$; + diff --git a/hub/src/error-capture/dispatcher.ts b/hub/src/error-capture/dispatcher.ts index ddf2e46..beaa2e9 100644 --- a/hub/src/error-capture/dispatcher.ts +++ b/hub/src/error-capture/dispatcher.ts @@ -33,6 +33,7 @@ import * as queue from '../scheduler/session-queue.ts' import { notifyThrottled } from './notify.ts' import { buildErrorMessage } from './prompt.ts' import { registerErrorRunForSession } from './run-lifecycle.ts' +import { checkUserThreshold } from '../usage/threshold.ts' export type DispatchOutcome = | { status: 'dispatched'; run_id: string } @@ -66,6 +67,24 @@ export async function dispatchPendingError(errorId: string): Promise { const now = new Date() const userId = task.user_id + // Claude usage threshold gate — sits in front of the cost-cap gate. + // Same shape, distinct status ('skipped_quota'). Persisting the run row + // (rather than silently dropping) is required for the run-history drawer + // and matches the cost-cap audit pattern. + const threshold = await checkUserThreshold(userId) + if (!threshold.allowed) { + const errMsg = `quota_threshold_reached:${threshold.reason}:${threshold.utilization_pct}>=${threshold.threshold_pct}` + const run = await insertRunV2({ + task_id: task.id, + user_id: userId, + status: 'skipped_quota', + scheduled_for: now, + target_kind: task.target_kind, + target_id: task.target_id, + triggered_by_run_id: opts.triggeredByRunId ?? null, + error: errMsg, + }) + broadcastScheduledRun(userId, { + type: 'scheduled_run_finished', + run_id: run.id, task_id: task.id, status: 'skipped_quota', error: errMsg, + }) + void onRunFinalized(task, run.id, 'skipped_quota', errMsg) + if (!opts.skipCronUpdate) updateFireTimestamps(task.id, now) + return + } + if (await isOverCostCap(userId, task.timezone)) { const run = await insertRunV2({ task_id: task.id, @@ -437,6 +464,14 @@ export function init(): void { if (!ctx) return const task = await getTaskById(ctx.taskId) if (!task) return + // Re-evaluate the threshold gate at waiter promotion — the user may have + // crossed the cap while the run was queued. Drop with skipped_quota. + const t = await checkUserThreshold(ctx.userId) + if (!t.allowed) { + const errMsg = `quota_threshold_reached:${t.reason}:${t.utilization_pct}>=${t.threshold_pct}` + await finalizeRun(runId, 'skipped_quota', errMsg) + return + } void routeToSender(task, ctx).catch((err) => console.error( `[scheduler.dispatcher] promoted send failed run=${runId} session=${sessionId}: ${err?.message}`, diff --git a/hub/src/scheduler/senders/triage.ts b/hub/src/scheduler/senders/triage.ts index 41605de..9d9d59a 100644 --- a/hub/src/scheduler/senders/triage.ts +++ b/hub/src/scheduler/senders/triage.ts @@ -60,6 +60,14 @@ export async function sendTriage( payload: TriagePayload, ): Promise { const pick = await pickSessionTarget(ctx.userId) + if (pick.kind === 'quota_blocked') { + await finalizeRun( + ctx.runId, + 'skipped_quota', + `quota_threshold_reached:${pick.reason}:${pick.utilization_pct}>=${pick.threshold_pct}`, + ) + return + } if (pick.kind === 'none') { await finalizeRun(ctx.runId, 'failed', 'no_target_available') return diff --git a/hub/src/sessions/routing.ts b/hub/src/sessions/routing.ts index 9cfcb0e..2405e6a 100644 --- a/hub/src/sessions/routing.ts +++ b/hub/src/sessions/routing.ts @@ -24,6 +24,7 @@ import { sql } from '../db/postgres.ts' import { reserveSessionSlot } from './budget.ts' import { isSupervisorOnline } from '../ws/supervisor-registry.ts' import { listOnlineAgentSessionsForUser } from '../ws/registry.ts' +import { checkUserThreshold } from '../usage/threshold.ts' // Recency threshold for "supervisor is online". Belt-and-braces with the // in-memory registry check — guards against zombie rows whose WS closed @@ -34,6 +35,13 @@ export type PickedTarget = | { kind: 'supervisor'; supervisor_id: string; running: number; cap: number } | { kind: 'local_agent'; agent_session_id: string } | { kind: 'none'; reason: string } + | { + kind: 'quota_blocked' + reason: 'session_threshold' | 'week_threshold' + utilization_pct: number + threshold_pct: number + resets_at: string + } interface SupervisorRow { id: string @@ -53,6 +61,20 @@ export async function pickSessionTarget( ): Promise { const exclude = new Set(opts.excludeSupervisorIds ?? []) + // Step 0: Claude usage threshold gate. Blocks new dispatch when the user + // is over their configured 5h or 7d cap. In-flight runs are not killed — + // only NEW target picks are refused. See review §3. + const t = await checkUserThreshold(userId) + if (!t.allowed) { + return { + kind: 'quota_blocked', + reason: t.reason!, + utilization_pct: t.utilization_pct ?? 0, + threshold_pct: t.threshold_pct ?? 0, + resets_at: t.resets_at ?? '', + } + } + // Step 1: preferred supervisor. const userRows = await sql<{ preferred_supervisor_id: string | null }[]>` SELECT preferred_supervisor_id FROM users WHERE id = ${userId} diff --git a/hub/src/usage/threshold.ts b/hub/src/usage/threshold.ts new file mode 100644 index 0000000..6f0fb4f --- /dev/null +++ b/hub/src/usage/threshold.ts @@ -0,0 +1,118 @@ +/** + * Claude usage threshold gate. + * + * Compares the in-memory `UsageSnapshot` (from `hub/src/usage/store.ts`, + * populated by the agent's 5-min OAuth poller) against the user's configured + * thresholds and returns an allow / block decision. + * + * Semantics (per .planning/phases/claude-usage-thresholds/00-architecture-review.md §3): + * 1. Both thresholds NULL → allowed (back-compat — gate OFF). + * 2. snapshot null → allowed (fail-open; no usage data yet). + * 3. five_hour ≥ session_threshold → blocked, reason='session_threshold'. + * 4. seven_day (or seven_day_opus) ≥ week_threshold → blocked, reason='week_threshold'. + * 5. Otherwise → allowed. + * + * Session-threshold takes precedence over week-threshold when both trip + * (deterministic — caller gets the most-imminent reset window). + * + * Utilization is already a percentage (0-100) in the stored payload — see + * `web/src/components/UsageStrip.tsx` which clamps to that range. + */ +import { getUsage, type UsageSnapshot } from './store.ts' +import { getUserClaudeThresholds } from '../db/dal.ts' + +export interface ThresholdDecision { + allowed: boolean + reason?: 'session_threshold' | 'week_threshold' + utilization_pct?: number + threshold_pct?: number + resets_at?: string +} + +export interface ThresholdsRow { + claude_session_threshold_pct: number | null + claude_week_threshold_pct: number | null +} + +const ALLOW: ThresholdDecision = { allowed: true } + +/** + * Pure decision function — no DB / no store reads. Suitable for unit testing. + */ +export function evaluateThreshold( + snap: UsageSnapshot | null, + thresholds: ThresholdsRow, +): ThresholdDecision { + const sessionPct = thresholds.claude_session_threshold_pct + const weekPct = thresholds.claude_week_threshold_pct + + // (1) Gate fully OFF. + if (sessionPct == null && weekPct == null) return ALLOW + + // (2) No data yet — fail-open (matches enforceCostCap behaviour). + if (!snap) return ALLOW + + const fiveHourUtil = snap.usage.five_hour?.utilization ?? 0 + const sevenDayUtil = snap.usage.seven_day?.utilization ?? 0 + const sevenDayOpusUtil = snap.usage.seven_day_opus?.utilization ?? 0 + + // (3) Session-window check wins ties — soonest reset. + if (sessionPct != null && fiveHourUtil >= sessionPct) { + return { + allowed: false, + reason: 'session_threshold', + utilization_pct: Math.round(fiveHourUtil), + threshold_pct: sessionPct, + resets_at: snap.usage.five_hour.resets_at, + } + } + + // (4) Weekly cap — opus carve-out counts toward the same gate per review §3. + if (weekPct != null) { + const breachingUtil = Math.max(sevenDayUtil, sevenDayOpusUtil) + if (breachingUtil >= weekPct) { + const opusBreaches = sevenDayOpusUtil >= weekPct && sevenDayOpusUtil >= sevenDayUtil + const window = opusBreaches ? snap.usage.seven_day_opus! : snap.usage.seven_day + return { + allowed: false, + reason: 'week_threshold', + utilization_pct: Math.round(breachingUtil), + threshold_pct: weekPct, + resets_at: window.resets_at, + } + } + } + + return ALLOW +} + +/** + * Convenience wrapper: load thresholds + snapshot, then evaluate. Used at + * every dispatch entry point (scheduler dispatcher, pickSessionTarget, + * error-capture dispatcher, manual chat send). + */ +export async function checkUserThreshold(userId: string): Promise { + const thresholds = await getUserClaudeThresholds(userId) + if (thresholds.claude_session_threshold_pct == null && thresholds.claude_week_threshold_pct == null) { + return ALLOW + } + const snap = getUsage(userId) + return evaluateThreshold(snap, thresholds) +} + +/** Stable error body shape for HTTP 503 refusals across all gate sites. */ +export function buildRefusalPayload(decision: ThresholdDecision): { + error: 'quota_threshold_reached' + reason: 'session_threshold' | 'week_threshold' + utilization_pct: number + threshold_pct: number + resets_at: string +} { + return { + error: 'quota_threshold_reached', + reason: decision.reason!, + utilization_pct: decision.utilization_pct ?? 0, + threshold_pct: decision.threshold_pct ?? 0, + resets_at: decision.resets_at ?? '', + } +} diff --git a/hub/src/ws/client.ts b/hub/src/ws/client.ts index 6340d5f..d09a425 100644 --- a/hub/src/ws/client.ts +++ b/hub/src/ws/client.ts @@ -5,6 +5,7 @@ import { verifyAuthSessionToken } from '../session.ts' import { verifyCsrfPair } from '../csrf.ts' import { config } from '../config.ts' import { insertMessage, listSessions, getSession } from '../db/dal' +import { checkUserThreshold } from '../usage/threshold.ts' import { registerClient, unregisterClient, subscribeClient, getChannel, unregisterChannel, broadcastToSubscribers, @@ -245,6 +246,26 @@ export async function handleClientMessage(ws: ServerWebSocket, raw return } + // Claude usage threshold gate — refuses NEW manual dispatches when the + // user is over their configured cap. Send a structured refusal back so + // the UI can surface the "paused" banner inline. + const threshold = await checkUserThreshold(data.userId!) + if (!threshold.allowed) { + try { + ws.send(JSON.stringify({ + type: 'send_refused', + client_id: msg.id, + session_id: msg.session_id, + error: 'quota_threshold_reached', + reason: threshold.reason, + utilization_pct: threshold.utilization_pct, + threshold_pct: threshold.threshold_pct, + resets_at: threshold.resets_at, + })) + } catch {} + return + } + // Embed images as markdown data URIs so they render in the chat history let storedContent = msg.content if (msg.images?.length) { diff --git a/hub/test/threshold.test.ts b/hub/test/threshold.test.ts new file mode 100644 index 0000000..7989fae --- /dev/null +++ b/hub/test/threshold.test.ts @@ -0,0 +1,111 @@ +/** + * Unit tests for the Claude usage threshold gate. + * + * Pure-function `evaluateThreshold` — no DB, no WS, no store. The integration + * with `checkUserThreshold` (DB + store) is exercised via the existing + * scheduler/error-capture/manual-send code paths in scheduled-tasks.e2e.test.ts. + */ +import { describe, test, expect } from 'bun:test' +import { evaluateThreshold } from '../src/usage/threshold.ts' +import type { UsageSnapshot } from '../src/usage/store.ts' + +function snap(fivePct: number, sevenPct: number, opusPct?: number | null): UsageSnapshot { + return { + usage: { + five_hour: { utilization: fivePct, resets_at: '2026-05-25T20:00:00Z' }, + seven_day: { utilization: sevenPct, resets_at: '2026-06-01T00:00:00Z' }, + seven_day_opus: opusPct == null + ? null + : { utilization: opusPct, resets_at: '2026-06-01T00:00:00Z' }, + }, + updated_at: '2026-05-25T19:55:00Z', + } +} + +describe('evaluateThreshold', () => { + test('back-compat: both thresholds null → allowed', () => { + expect(evaluateThreshold(snap(99, 99), { + claude_session_threshold_pct: null, + claude_week_threshold_pct: null, + })).toEqual({ allowed: true }) + }) + + test('snapshot null with thresholds set → fail-open (allowed)', () => { + expect(evaluateThreshold(null, { + claude_session_threshold_pct: 90, + claude_week_threshold_pct: 90, + })).toEqual({ allowed: true }) + }) + + test('session window over threshold → blocked, reason=session_threshold', () => { + const d = evaluateThreshold(snap(92, 10), { + claude_session_threshold_pct: 90, + claude_week_threshold_pct: 95, + }) + expect(d.allowed).toBe(false) + expect(d.reason).toBe('session_threshold') + expect(d.utilization_pct).toBe(92) + expect(d.threshold_pct).toBe(90) + expect(d.resets_at).toBe('2026-05-25T20:00:00Z') + }) + + test('week window over threshold → blocked, reason=week_threshold', () => { + const d = evaluateThreshold(snap(10, 92), { + claude_session_threshold_pct: 95, + claude_week_threshold_pct: 90, + }) + expect(d.allowed).toBe(false) + expect(d.reason).toBe('week_threshold') + expect(d.utilization_pct).toBe(92) + expect(d.resets_at).toBe('2026-06-01T00:00:00Z') + }) + + test('both over → session_threshold wins (deterministic precedence)', () => { + const d = evaluateThreshold(snap(95, 99), { + claude_session_threshold_pct: 90, + claude_week_threshold_pct: 90, + }) + expect(d.reason).toBe('session_threshold') + }) + + test('boundary at exactly threshold_pct → blocked (>=)', () => { + const d = evaluateThreshold(snap(90, 10), { + claude_session_threshold_pct: 90, + claude_week_threshold_pct: null, + }) + expect(d.allowed).toBe(false) + expect(d.reason).toBe('session_threshold') + }) + + test('just under threshold → allowed', () => { + const d = evaluateThreshold(snap(89.9, 10), { + claude_session_threshold_pct: 90, + claude_week_threshold_pct: null, + }) + expect(d.allowed).toBe(true) + }) + + test('opus carve-out trips week_threshold', () => { + const d = evaluateThreshold(snap(10, 50, 95), { + claude_session_threshold_pct: null, + claude_week_threshold_pct: 90, + }) + expect(d.allowed).toBe(false) + expect(d.reason).toBe('week_threshold') + expect(d.utilization_pct).toBe(95) + }) + + test('only session threshold set, week unbounded', () => { + expect(evaluateThreshold(snap(50, 99), { + claude_session_threshold_pct: 90, + claude_week_threshold_pct: null, + })).toEqual({ allowed: true }) + }) + + test('only week threshold set, session unbounded', () => { + expect(evaluateThreshold(snap(99, 50), { + claude_session_threshold_pct: null, + claude_week_threshold_pct: 90, + })).toEqual({ allowed: true }) + }) +}) diff --git a/web/src/components/ClaudeUsageCard.tsx b/web/src/components/ClaudeUsageCard.tsx new file mode 100644 index 0000000..1b8830c --- /dev/null +++ b/web/src/components/ClaudeUsageCard.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react' +import { hubFetch } from '../lib/api' + +interface Props { token: string } + +interface ThresholdsResponse { + session_pct: number | null + week_pct: number | null +} + +interface UsageResponse { + usage: { + five_hour: { utilization: number; resets_at: string } + seven_day: { utilization: number; resets_at: string } + seven_day_opus?: { utilization: number; resets_at: string } | null + } | null + thresholds: { session_pct: number | null; week_pct: number | null } + paused: boolean + reason: string | null + utilization_pct: number | null + threshold_pct: number | null + resets_at: string | null +} + +const MIN_VAL = 50 +const MAX_VAL = 95 +const STEP = 5 + +/** + * Settings → Profile → "Claude usage threshold gate" card. + * NULL = off. Inputs default-suggested at 90 but are not persisted until Save. + */ +export function ClaudeUsageCard({ token }: Props) { + const [loading, setLoading] = useState(true) + const [sessionPct, setSessionPct] = useState(null) + const [weekPct, setWeekPct] = useState(null) + const [sessionEnabled, setSessionEnabled] = useState(false) + const [weekEnabled, setWeekEnabled] = useState(false) + const [usage, setUsage] = useState(null) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + Promise.all([ + hubFetch(token, '/api/account/claude-thresholds'), + hubFetch(token, '/api/account/usage').catch(() => null), + ]).then(([t, u]) => { + if (cancelled) return + setSessionPct(t.session_pct) + setWeekPct(t.week_pct) + setSessionEnabled(t.session_pct != null) + setWeekEnabled(t.week_pct != null) + setUsage(u) + setLoading(false) + }).catch(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [token]) + + async function handleSave() { + setSaving(true); setErr(null); setSaved(false) + try { + const body = { + session_pct: sessionEnabled ? (sessionPct ?? 90) : null, + week_pct: weekEnabled ? (weekPct ?? 90) : null, + } + const res = await hubFetch(token, '/api/account/claude-thresholds', { + method: 'PUT', + json: body, + }) + setSessionPct(res.session_pct) + setWeekPct(res.week_pct) + setSaved(true) + setTimeout(() => setSaved(false), 2500) + // Refresh usage decision + try { + const u = await hubFetch(token, '/api/account/usage') + setUsage(u) + } catch {} + } catch (e: any) { + setErr(e?.message ?? 'save_failed') + } finally { + setSaving(false) + } + } + + const fiveHourPct = usage?.usage ? Math.round(usage.usage.five_hour.utilization) : null + const weekUtilPct = usage?.usage ? Math.round(usage.usage.seven_day.utilization) : null + + const proposedSessionTrips = + sessionEnabled && fiveHourPct != null && fiveHourPct >= (sessionPct ?? 90) + const proposedWeekTrips = + weekEnabled && weekUtilPct != null && weekUtilPct >= (weekPct ?? 90) + + return ( +
+

Claude usage threshold gate

+

+ When Claude usage reaches the configured percentage, remo-code pauses NEW dispatches + (scheduled tasks, error-capture, manual chat sends, healed sessions) until the window + resets. In-flight sessions are NOT killed. NULL = OFF. +

+ + {usage?.paused && ( +
+ Paused — {usage.reason === 'session_threshold' ? '5-hour' : '7-day'} usage at {usage.utilization_pct}% + {usage.resets_at && <> · resets {new Date(usage.resets_at).toLocaleString()}} +
+ )} + + {loading ? ( +
Loading…
+ ) : ( +
+ + + +
+ + {saved && Saved} + {err && {err}} + {(proposedSessionTrips || proposedWeekTrips) && ( + ⚠ Saving now will immediately pause dispatch + )} +
+
+ )} +
+ ) +} + +interface SliderProps { + label: string + help: string + enabled: boolean + onToggle: (v: boolean) => void + value: number + onChange: (v: number) => void + currentUtil: number | null + warn: boolean +} + +function ThresholdSlider({ label, help, enabled, onToggle, value, onChange, currentUtil, warn }: SliderProps) { + const clamped = Math.max(MIN_VAL, Math.min(MAX_VAL, value)) + return ( +
+
+
+
{label}
+
{help}
+
+ +
+ + {enabled && ( +
+ onChange(parseInt(e.target.value, 10))} + className="flex-1 accent-indigo-500" + /> + + {clamped}% + + {currentUtil != null && ( + + now: {currentUtil}% + + )} +
+ )} +
+ ) +} diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index 39b8ab0..d6da3e5 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -6,6 +6,7 @@ import { useWebSocket } from '../hooks/useWebSocket' import { SupervisorPage } from './SupervisorPage' import { CommandsList } from './CommandsList' import { SchedulesPage } from './SchedulesPage' +import { ClaudeUsageCard } from './ClaudeUsageCard' import { hubFetch } from '../lib/api' interface Props { @@ -155,6 +156,8 @@ export function SettingsPage({ token, profile, onUpdateProfile, onBack }: Props) + +