diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..05a7337
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed0bd40..56df09a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
verify:
name: Verify Stability
@@ -46,10 +49,10 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: 'npm'
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..313d1dd
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,33 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: '0 0 * * 1'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write
+
+ strategy:
+ matrix:
+ language: ['javascript-typescript']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..15205df
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,28 @@
+# Changelog
+
+## v0.9.2
+
+### What's New
+
+- **UI Redesign:** Full visual overhaul of the dashboard, landing page, and auth flows with an updated color system, improved dark mode, and a cleaner layout overall.
+- **Code Splitting:** All pages now use `React.lazy` for async loading, reducing initial bundle size and improving load times.
+- **Custom LLM Providers:** Manage custom OpenAI-compatible API providers directly from the dashboard. Rate limits are optional. No more hardcoded keys in wrangler config.
+- **Cloudflare Setup Script:** A new Node.js script automates the full Cloudflare deployment setup, making self-hosting significantly easier.
+- **Increased Review Capacity:** Max files processed per review raised from 15 to 100.
+
+### Improvements
+
+- Review jobs are now resumable and lease-aware, stalled reviews can recover automatically.
+- Added retry logic for transient model provider failures.
+- Optimized job polling with ETag caching and adaptive delays.
+- Improved settings UI, error reporting, and API robustness across the board.
+- Added GitHub Actions CI with a disposable test environment for the full test suite.
+
+### Bug Fixes
+
+- Fixed `APP_PRIVATE_KEY` parsing for single-line strings with literal `\n` sequences.
+- Fixed database migration failures on existing deployments.
+- Fixed duplicate `/api/auth/updates-email` calls on page load.
+- Fixed file review status bar rendering on the dashboard.
+
+**Full Changelog**: https://github.com/devarshishimpi/codra/commits/v0.9.2
diff --git a/README.md b/README.md
index 13d2b6b..968e084 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,6 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- Mention-triggered reviews for on-demand analysis
- Inline GitHub review comments plus summary reviews and check run updates
- Queue-backed processing through Cloudflare Queues
-- Dead letter queue inspection, replay, and purge workflows
- GitHub OAuth dashboard authentication
- External PostgreSQL storage through Cloudflare Hyperdrive
- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models
@@ -66,7 +65,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- **Worker**: Cloudflare Workers, Hono, Wrangler
- **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts
- **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV
-- **Queues**: Cloudflare Queues with DLQ workflows
+- **Queues**: Cloudflare Queues and Workflows
- **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers
- **GitHub**: GitHub App webhooks, checks, reviews, and OAuth
- **Quality**: TypeScript, Zod, Vitest, Playwright browser tests
diff --git a/db/migrations/004_review_performance_settings.sql b/db/migrations/004_review_performance_settings.sql
new file mode 100644
index 0000000..794c252
--- /dev/null
+++ b/db/migrations/004_review_performance_settings.sql
@@ -0,0 +1,4 @@
+INSERT INTO global_settings (key, value) VALUES
+ ('review_concurrency_level', 'medium'),
+ ('review_max_comments', '10')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/005_drop_model_rate_limits.sql b/db/migrations/005_drop_model_rate_limits.sql
new file mode 100644
index 0000000..a0daa35
--- /dev/null
+++ b/db/migrations/005_drop_model_rate_limits.sql
@@ -0,0 +1,3 @@
+ALTER TABLE model_configs DROP COLUMN IF EXISTS rpm;
+ALTER TABLE model_configs DROP COLUMN IF EXISTS tpm;
+ALTER TABLE model_configs DROP COLUMN IF EXISTS rpd;
diff --git a/db/migrations/006_job_continuation_count.sql b/db/migrations/006_job_continuation_count.sql
new file mode 100644
index 0000000..2e2cc8b
--- /dev/null
+++ b/db/migrations/006_job_continuation_count.sql
@@ -0,0 +1,5 @@
+-- Tracks how many times a job has rescheduled the *same* phase without completing any
+-- file review (a "no-progress continuation"). Reset to 0 whenever a chunk makes progress.
+-- A hard ceiling on this counter (see MAX_JOB_CONTINUATIONS in review.ts) stops a job that
+-- can never make headway from churning indefinitely on transient/budget deferrals.
+ALTER TABLE jobs ADD COLUMN IF NOT EXISTS continuation_count INT NOT NULL DEFAULT 0;
diff --git a/package-lock.json b/package-lock.json
index 95516b1..cf5aeb8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"clsx": "^2.1.1",
"hono": "^4.12.12",
"jsonrepair": "^3.13.3",
+ "lenis": "^1.3.25",
"lucide-react": "^1.8.0",
"motion": "^12.42.2",
"picomatch": "^4.0.4",
@@ -4772,6 +4773,37 @@
"node": ">=6"
}
},
+ "node_modules/lenis": {
+ "version": "1.3.25",
+ "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.25.tgz",
+ "integrity": "sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*",
+ "playground",
+ "playground/*"
+ ],
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/darkroomengineering"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "react": ">=17.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
diff --git a/package.json b/package.json
index 682e74a..2fce114 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"clsx": "^2.1.1",
"hono": "^4.12.12",
"jsonrepair": "^3.13.3",
+ "lenis": "^1.3.25",
"lucide-react": "^1.8.0",
"motion": "^12.42.2",
"picomatch": "^4.0.4",
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index bff796f..07ae8b5 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -271,15 +271,6 @@ async function ensureModelCatalog() {
await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID');
await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT');
- await query('ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL');
- await query('ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL');
- await query('ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL');
- await query(`
- UPDATE model_configs
- SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now()
- WHERE rpm = 1 AND tpm = 1 AND rpd = 1
- `);
-
await query(
`
UPDATE model_configs mc
@@ -317,14 +308,11 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT $1, 10, 131072, 300, 'cloudflare', p.id, $1, now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT $1, 'cloudflare', p.id, $1, now()
FROM llm_providers p
WHERE p.name = 'Cloudflare'
ON CONFLICT (model_id) DO UPDATE SET
- rpm = EXCLUDED.rpm,
- tpm = EXCLUDED.tpm,
- rpd = EXCLUDED.rpd,
provider = EXCLUDED.provider,
provider_id = EXCLUDED.provider_id,
model_name = EXCLUDED.model_name,
@@ -335,14 +323,11 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT '@cf/zai-org/glm-4.7-flash', 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now()
FROM llm_providers p
WHERE p.name = 'Cloudflare'
ON CONFLICT (model_id) DO UPDATE SET
- rpm = EXCLUDED.rpm,
- tpm = EXCLUDED.tpm,
- rpd = EXCLUDED.rpd,
provider = EXCLUDED.provider,
provider_id = EXCLUDED.provider_id,
model_name = EXCLUDED.model_name,
@@ -352,8 +337,8 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT 'gemma-4-31b-it', 'gemini', p.id, 'gemma-4-31b-it', now()
FROM llm_providers p
WHERE p.name = 'Google'
ON CONFLICT (model_id) DO UPDATE SET
@@ -366,8 +351,8 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT 'gemma-4-26b-a4b-it', 'gemini', p.id, 'gemma-4-26b-a4b-it', now()
FROM llm_providers p
WHERE p.name = 'Google'
ON CONFLICT (model_id) DO UPDATE SET
diff --git a/src/client/app.css b/src/client/app.css
index b761a31..289d725 100644
--- a/src/client/app.css
+++ b/src/client/app.css
@@ -903,8 +903,7 @@
gap: 0.625rem !important;
padding: 0.75rem 0.875rem !important;
border-radius: 0.625rem !important;
- border-width: 1px !important;
- border-style: solid !important;
+ border: none !important;
font-family: var(--font-sans) !important;
font-size: 0.8125rem !important;
line-height: 1.45 !important;
@@ -915,7 +914,6 @@
/* light defaults (overridden per-variant below) */
background: oklch(99.5% 0.004 115) !important;
- border-color: oklch(88% 0.008 115) !important;
color: oklch(15% 0.02 115) !important;
/* smooth entrance */
@@ -924,7 +922,6 @@
.dark .codra-toast {
background: oklch(13% 0.018 115) !important;
- border-color: oklch(22% 0.022 115) !important;
color: oklch(94% 0.006 115) !important;
box-shadow:
0 6px 24px oklch(0% 0 0 / 0.5),
@@ -983,64 +980,9 @@
background: oklch(28% 0.022 115) !important;
}
-/* ── SUCCESS ─────────────────────────────────────── */
-.codra-toast-success {
- background: oklch(98.5% 0.045 115) !important;
- border-color: oklch(82% 0.16 115) !important;
- color: oklch(28% 0.10 115) !important;
-}
-
-.dark .codra-toast-success {
- background: oklch(16% 0.08 115) !important;
- border-color: oklch(32% 0.14 115) !important;
- color: oklch(90% 0.18 115) !important;
-}
-
-.codra-toast-success .codra-toast-description {
- color: oklch(38% 0.10 115) !important;
- opacity: 0.85 !important;
-}
-
-.dark .codra-toast-success .codra-toast-description {
- color: oklch(72% 0.12 115) !important;
- opacity: 0.9 !important;
-}
-
-/* ── ERROR ───────────────────────────────────────── */
-.codra-toast-error {
- background: oklch(98.5% 0.03 25) !important;
- border-color: oklch(80% 0.14 25) !important;
- color: oklch(32% 0.14 25) !important;
-}
-
-.dark .codra-toast-error {
- background: oklch(15% 0.07 25) !important;
- border-color: oklch(35% 0.14 25) !important;
- color: oklch(85% 0.08 25) !important;
-}
-
-.codra-toast-error .codra-toast-description {
- color: oklch(42% 0.12 25) !important;
- opacity: 0.85 !important;
-}
-
-.dark .codra-toast-error .codra-toast-description {
- color: oklch(68% 0.10 25) !important;
- opacity: 0.9 !important;
-}
-
-/* ── LOADING ─────────────────────────────────────── */
-.codra-toast-loading {
- background: oklch(98% 0.004 115) !important;
- border-color: oklch(86% 0.010 115) !important;
- color: oklch(20% 0.020 115) !important;
-}
-
-.dark .codra-toast-loading {
- background: oklch(14% 0.020 115) !important;
- border-color: oklch(24% 0.025 115) !important;
- color: oklch(88% 0.008 115) !important;
-}
+/* ── SUCCESS / ERROR / LOADING ─────────────────────
+ Use the default toast text color instead of a
+ status tint (icon color already conveys status). */
/* spinner inherits accent color */
.codra-toast-loader svg {
@@ -1049,26 +991,18 @@
/* ── WARNING ─────────────────────────────────────── */
.codra-toast-warning {
- background: oklch(98.5% 0.04 65) !important;
- border-color: oklch(82% 0.13 65) !important;
- color: oklch(35% 0.12 65) !important;
+ color: oklch(35% 0.12 65) !important;
}
.dark .codra-toast-warning {
- background: oklch(16% 0.08 65) !important;
- border-color: oklch(35% 0.14 65) !important;
- color: oklch(82% 0.14 65) !important;
+ color: oklch(82% 0.14 65) !important;
}
/* ── INFO ────────────────────────────────────────── */
.codra-toast-info {
- background: oklch(98.5% 0.03 250) !important;
- border-color: oklch(80% 0.12 250) !important;
- color: oklch(30% 0.12 250) !important;
+ color: oklch(30% 0.12 250) !important;
}
.dark .codra-toast-info {
- background: oklch(15% 0.07 250) !important;
- border-color: oklch(33% 0.12 250) !important;
- color: oklch(80% 0.12 250) !important;
+ color: oklch(80% 0.12 250) !important;
}
diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx
index c972f5e..c048610 100644
--- a/src/client/components/features/job-detail/file-finding.tsx
+++ b/src/client/components/features/job-detail/file-finding.tsx
@@ -23,7 +23,7 @@ export function FileFinding({ file }: FileFindingProps) {
-
+ {file.fileStatus === 'done' && }
{file.parsedComments.length > 0 && (
{file.parsedComments.length}
diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx
index 084eb1f..a1b4b32 100644
--- a/src/client/components/features/job-detail/job-findings-list.tsx
+++ b/src/client/components/features/job-detail/job-findings-list.tsx
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { FileText } from 'lucide-react';
-import { cn } from '@client/lib/utils';
import type { JobDetail } from '@shared/schema';
import { reviewSeverities } from '@shared/schema';
+import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs';
import { FileFinding } from './file-finding';
import { CommentCard } from './comment-card';
import { severityConfig } from './constants';
@@ -24,22 +24,16 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
View by
-
- {(['files', 'severity'] as const).map((view) => (
- setViewBy(view)}
- className={cn(
- 'rounded-md px-3 py-1.5 text-xs font-semibold capitalize transition-all',
- viewBy === view
- ? 'bg-card text-foreground shadow-sm'
- : 'text-muted-foreground hover:text-foreground',
- )}
- >
- {view}
-
- ))}
-
+
setViewBy(v as 'files' | 'severity')} variant="segment">
+
+
+ Files
+
+
+ Severity
+
+
+
@@ -59,6 +53,30 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
) : (
+ {job.files.some((f) => f.fileStatus === 'failed') && (
+
+ {/* Group header */}
+
+
+
+ Failed Files
+
+
+ {job.files.filter((f) => f.fileStatus === 'failed').length}
+
+
+ {/* Failed files list */}
+
+ {job.files.filter((f) => f.fileStatus === 'failed').map((file) => (
+
+ ))}
+
+
+ )}
+
{reviewSeverities.map((groupName) => {
const comments = job.files.flatMap((f) =>
f.parsedComments
diff --git a/src/client/components/motion/smooth-scroll.tsx b/src/client/components/motion/smooth-scroll.tsx
new file mode 100644
index 0000000..53aa6fa
--- /dev/null
+++ b/src/client/components/motion/smooth-scroll.tsx
@@ -0,0 +1,269 @@
+// beui.dev/components/motion/scroll-animation
+import type Lenis from 'lenis';
+import { ReactLenis, useLenis } from 'lenis/react';
+import { type MotionValue, useMotionValue, useReducedMotion } from 'motion/react';
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+} from 'react';
+
+// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a
+// named local fn (not a lib/ease token) because tokens are bezier control
+// points for the motion lib, while Lenis needs a (t) => number easing fn.
+const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t));
+
+export type ScrollTarget = number | string | HTMLElement;
+
+export type ScrollToOptions = {
+ offset?: number;
+ immediate?: boolean;
+ duration?: number;
+};
+
+export type SmoothScrollApi = {
+ /** Underlying Lenis instance, or null on the reduced-motion / native path. */
+ lenis: Lenis | null;
+ /** Current scroll offset in px. */
+ scrollY: MotionValue
;
+ /** Scroll position as 0..1 of the scrollable height. */
+ progress: MotionValue;
+ /** Signed scroll velocity (px/frame); drives velocity-based effects. */
+ velocity: MotionValue;
+ /** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */
+ scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
+};
+
+const SmoothScrollContext = createContext(null);
+
+export interface SmoothScrollProps {
+ children: ReactNode;
+ /** Drive the page (window) when true, or a contained scroll area when false. */
+ root?: boolean;
+ /** Smoothing factor; lower is smoother and heavier. */
+ lerp?: number;
+ /** Wheel / programmatic ease duration in seconds. */
+ duration?: number;
+ orientation?: 'vertical' | 'horizontal';
+ /** Wheel scroll speed multiplier. */
+ wheelMultiplier?: number;
+ /** Smooth touch scrolling. Off by default — native momentum is good on mobile. */
+ touch?: boolean;
+ className?: string;
+}
+
+type ScrollSource = Window | HTMLElement;
+
+function readMetrics(target: ScrollSource) {
+ if (target instanceof Window) {
+ const max = Math.max(
+ 0,
+ document.documentElement.scrollHeight - window.innerHeight,
+ );
+ return { y: window.scrollY, max };
+ }
+ return {
+ y: target.scrollTop,
+ max: Math.max(0, target.scrollHeight - target.clientHeight),
+ };
+}
+
+function resolveTop(
+ target: ScrollTarget,
+ source: ScrollSource,
+ offset = 0,
+): number {
+ if (typeof target === 'number') return target + offset;
+ if (source instanceof Window) {
+ const el =
+ typeof target === 'string' ? document.querySelector(target) : target;
+ if (!el) return window.scrollY;
+ return el.getBoundingClientRect().top + window.scrollY + offset;
+ }
+ const el =
+ typeof target === 'string' ? source.querySelector(target) : target;
+ if (!(el instanceof HTMLElement)) return source.scrollTop;
+ return el.offsetTop + offset;
+}
+
+/** Pushes Lenis' live scroll state into the shared motion values. */
+function LenisBridge({
+ scrollY,
+ progress,
+ velocity,
+ lenisRef,
+}: {
+ scrollY: MotionValue;
+ progress: MotionValue;
+ velocity: MotionValue;
+ lenisRef: { current: Lenis | null };
+}) {
+ const lenis = useLenis((instance) => {
+ scrollY.set(instance.scroll);
+ progress.set(instance.progress);
+ velocity.set(instance.velocity);
+ });
+ useEffect(() => {
+ lenisRef.current = lenis ?? null;
+ return () => {
+ lenisRef.current = null;
+ };
+ }, [lenis, lenisRef]);
+ return null;
+}
+
+/** Native scroll listener for the reduced-motion path and the no-provider fallback. */
+function useNativeScrollSync(
+ enabled: boolean,
+ getTarget: () => ScrollSource | null,
+ scrollY: MotionValue,
+ progress: MotionValue,
+ velocity: MotionValue,
+) {
+ useEffect(() => {
+ if (!enabled) return;
+ const target = getTarget();
+ if (!target) return;
+ let lastY = readMetrics(target).y;
+ let lastT = performance.now();
+ const onScroll = () => {
+ const { y, max } = readMetrics(target);
+ const now = performance.now();
+ const dt = now - lastT || 16;
+ scrollY.set(y);
+ progress.set(max > 0 ? y / max : 0);
+ velocity.set(((y - lastY) / dt) * 16);
+ lastY = y;
+ lastT = now;
+ };
+ onScroll();
+ target.addEventListener('scroll', onScroll, { passive: true });
+ return () => target.removeEventListener('scroll', onScroll);
+ }, [enabled, getTarget, scrollY, progress, velocity]);
+}
+
+export function SmoothScroll({
+ children,
+ root = true,
+ lerp = 0.1,
+ duration = 1.2,
+ orientation = 'vertical',
+ wheelMultiplier = 1,
+ touch = false,
+ className,
+}: SmoothScrollProps) {
+ const reduce = useReducedMotion();
+ const scrollY = useMotionValue(0);
+ const progress = useMotionValue(0);
+ const velocity = useMotionValue(0);
+ const lenisRef = useRef(null);
+ const containerRef = useRef(null);
+
+ const nativeSource = useCallback(
+ (): ScrollSource | null => (root ? window : containerRef.current),
+ [root],
+ );
+
+ const scrollTo = useCallback(
+ (target: ScrollTarget, options?: ScrollToOptions) => {
+ const lenis = lenisRef.current;
+ if (lenis && !reduce) {
+ lenis.scrollTo(target, {
+ offset: options?.offset,
+ duration: options?.duration,
+ immediate: options?.immediate,
+ });
+ return;
+ }
+ const source = nativeSource();
+ const behavior = reduce || options?.immediate ? 'auto' : 'smooth';
+ const top = resolveTop(target, source ?? window, options?.offset);
+ (source ?? window).scrollTo({ top, behavior });
+ },
+ [reduce, nativeSource],
+ );
+
+ // Reduced motion drives the native listener; the Lenis path leaves it
+ // disabled and lets LenisBridge feed the values instead.
+ useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity);
+
+ const api = useMemo(
+ () => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }),
+ [scrollY, progress, velocity, scrollTo],
+ );
+
+ if (reduce) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Read the page's smooth-scroll state. Inside it returns the
+ * shared motion values; outside it falls back to a native window scroll
+ * listener so scroll-driven components still work without the provider.
+ */
+export function useSmoothScroll(): SmoothScrollApi {
+ const ctx = useContext(SmoothScrollContext);
+ const scrollY = useMotionValue(0);
+ const progress = useMotionValue(0);
+ const velocity = useMotionValue(0);
+
+ const windowSource = useCallback((): ScrollSource => window, []);
+ useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity);
+
+ const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => {
+ window.scrollTo({
+ top: resolveTop(target, window, options?.offset),
+ behavior: options?.immediate ? 'auto' : 'smooth',
+ });
+ }, []);
+
+ const fallback = useMemo(
+ () => ({ lenis: null, scrollY, progress, velocity, scrollTo }),
+ [scrollY, progress, velocity, scrollTo],
+ );
+
+ return ctx ?? fallback;
+}
diff --git a/src/client/components/motion/stepped-slider.tsx b/src/client/components/motion/stepped-slider.tsx
new file mode 100644
index 0000000..beb46ab
--- /dev/null
+++ b/src/client/components/motion/stepped-slider.tsx
@@ -0,0 +1,321 @@
+// Discrete-step slider with a spring-smoothed thumb and a live value readout
+// that sits statically above the track (no floating/portal-positioned
+// tooltip involved). Adapted from a min/max/step numeric range slider so
+// labeled steps (e.g. Low/Medium/High/Max) line up exactly on evenly spaced
+// stops.
+import {
+ motion,
+ useMotionTemplate,
+ useMotionValue,
+ useReducedMotion,
+ useSpring,
+ useTransform,
+} from 'motion/react';
+import {
+ type KeyboardEvent,
+ type PointerEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { cn } from '@client/lib/utils';
+import { useIsDarkMode } from '@client/hooks/use-is-dark-mode';
+
+const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const;
+const SPRING_BOUNCY = { type: 'spring', stiffness: 500, damping: 14, mass: 0.7 } as const;
+
+// Deterministic 0..1 seed derived from the slider's id, so two sliders maxed
+// out at the same time get slightly different hues and animation timing
+// instead of pulsing in perfect, obviously-copy-pasted unison.
+function seedFromId(id: string | undefined) {
+ if (!id) return 0;
+ let hash = 0;
+ for (let i = 0; i < id.length; i += 1) {
+ hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
+ }
+ return (hash % 997) / 997;
+}
+
+export interface SteppedSliderStep {
+ value: number;
+ label: string;
+}
+
+export interface SteppedSliderProps {
+ id?: string;
+ value?: number;
+ defaultValue?: number;
+ onValueChange?: (value: number) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+ /** Tick dots + labels rendered at each defined step. */
+ steps?: SteppedSliderStep[];
+ /** Live value readout shown above the track, and used as the accessible aria-valuetext. Defaults to the raw value. */
+ formatValue?: (value: number) => string;
+ disabled?: boolean;
+ className?: string;
+ 'aria-label'?: string;
+ 'aria-labelledby'?: string;
+}
+
+const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
+
+export function SteppedSlider({
+ id,
+ value,
+ defaultValue = 0,
+ onValueChange,
+ min = 0,
+ max = 100,
+ step = 1,
+ steps = [],
+ formatValue,
+ disabled = false,
+ className,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledBy,
+}: SteppedSliderProps) {
+ const reduce = useReducedMotion();
+ const isDark = useIsDarkMode();
+ const seed = useMemo(() => seedFromId(id), [id]);
+ const trackRef = useRef(null);
+ const [internal, setInternal] = useState(defaultValue);
+ const [active, setActive] = useState(false);
+ // Live position while dragging, decoupled from the committed value so that
+ // onValueChange fires exactly once per gesture (on release) instead of on
+ // every pointer-move tick — callers may react to a commit by opening a
+ // confirmation dialog, which must not re-fire while the pointer is still down.
+ const [dragValue, setDragValue] = useState(null);
+ const controlled = value !== undefined;
+ const committedValue = clamp(controlled ? (value as number) : internal, min, max);
+ const current = active && dragValue !== null ? clamp(dragValue, min, max) : committedValue;
+ const percent = ((current - min) / (max - min)) * 100;
+ const isMaxed = current === max;
+
+ // Teal accent for the "maxed out" state, hue-shifted slightly per instance
+ // and timed with a per-instance offset so simultaneous max sliders read as
+ // one cohesive design rather than a mirrored duplicate.
+ const hue = 185 + (seed - 0.5) * 16;
+ const glowColor = isDark ? `oklch(68% 0.14 ${hue})` : `oklch(52% 0.13 ${hue})`;
+ const fillGradient = isDark
+ ? `linear-gradient(90deg, oklch(28% 0.08 ${hue}), oklch(62% 0.12 ${hue}))`
+ : `linear-gradient(90deg, oklch(93% 0.03 ${hue}), oklch(56% 0.13 ${hue}))`;
+ const dotDriftDuration = 1.4 + seed * 0.5;
+ const glowDuration = 1.6 + seed * 0.6;
+
+ const target = useMotionValue(percent);
+ useEffect(() => {
+ target.set(percent);
+ }, [percent, target]);
+ const smooth = useSpring(target, SPRING_GLIDE);
+ const pos = reduce ? target : smooth;
+ const left = useMotionTemplate`${pos}%`;
+ const thumbX = useTransform(pos, (p) => `${-p}%`);
+
+ const snapValue = useCallback(
+ (next: number) => clamp(Math.round((next - min) / step) * step + min, min, max),
+ [min, max, step],
+ );
+
+ const commit = useCallback(
+ (next: number) => {
+ const snapped = snapValue(next);
+ if (!controlled) setInternal(snapped);
+ onValueChange?.(snapped);
+ },
+ [controlled, onValueChange, snapValue],
+ );
+
+ const valueFromX = useCallback(
+ (clientX: number) => {
+ const rect = trackRef.current?.getBoundingClientRect();
+ if (!rect) return committedValue;
+ const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
+ return min + ratio * (max - min);
+ },
+ [committedValue, min, max],
+ );
+
+ const onPointerDown = useCallback(
+ (event: PointerEvent) => {
+ if (disabled) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setActive(true);
+ setDragValue(snapValue(valueFromX(event.clientX)));
+ },
+ [disabled, valueFromX, snapValue],
+ );
+
+ const onPointerMove = useCallback(
+ (event: PointerEvent) => {
+ if (!active || disabled) return;
+ setDragValue(snapValue(valueFromX(event.clientX)));
+ },
+ [active, disabled, valueFromX, snapValue],
+ );
+
+ const endDrag = useCallback(
+ (event: PointerEvent) => {
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+ setActive(false);
+ setDragValue((pending) => {
+ if (pending !== null) commit(pending);
+ return null;
+ });
+ },
+ [commit],
+ );
+
+ const onKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (disabled) return;
+ const map: Record = {
+ ArrowRight: committedValue + step,
+ ArrowUp: committedValue + step,
+ ArrowLeft: committedValue - step,
+ ArrowDown: committedValue - step,
+ Home: min,
+ End: max,
+ };
+ if (event.key in map) {
+ event.preventDefault();
+ commit(map[event.key]);
+ }
+ },
+ [disabled, committedValue, step, min, max, commit],
+ );
+
+ const valueLabel = formatValue ? formatValue(current) : String(current);
+
+ return (
+
+ {/* live value readout — sits in normal flow above the track, always visible */}
+
+ {valueLabel}
+
+
+
+ {/* fill — runs from the left edge to the thumb; static teal gradient with a dot texture
+ steadily drifting left-to-right at the top step. Every dot has the same fixed
+ brightness — only position moves — so it reads as one smooth, consistent motion
+ instead of some dots being brighter than others. The pattern repeats every 10px and
+ shifts by exactly one period, so the loop point is never visible. */}
+
+ {isMaxed ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* ticks — slight inset so the end dots don't clip; sized/ringed up at the top step so they
+ read as distinct markers instead of blending into the dot texture behind them */}
+
+ {steps.map((tick) => {
+ const tp = ((tick.value - min) / (max - min)) * 100;
+ return (
+
+ );
+ })}
+
+
+ {/* pulsing glow halo behind the thumb at the top step — kept as its own layer so only
+ opacity (a plain number) is animated, since Motion's box-shadow interpolator can't
+ parse CSS custom properties inside the color stops */}
+ {isMaxed && !reduce && (
+
+ )}
+
+ {/* vertical bar thumb — contained at both ends via thumbX */}
+
+
+
+ {steps.length > 0 && (
+
+
+ {steps.map((tick, index) => {
+ const isFirst = index === 0;
+ const isLast = index === steps.length - 1;
+ const tp = ((tick.value - min) / (max - min)) * 100;
+ return (
+
+ {tick.label}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/client/components/motion/tabs.tsx b/src/client/components/motion/tabs.tsx
new file mode 100644
index 0000000..038883e
--- /dev/null
+++ b/src/client/components/motion/tabs.tsx
@@ -0,0 +1,189 @@
+// beui.dev/components/motion/tabs
+import { motion, MotionConfig, useReducedMotion, type Transition } from 'motion/react';
+import { createContext, useContext, useId, useState, type ReactNode } from 'react';
+import { EASE_OUT } from '@client/lib/ease';
+import { cn } from '@client/lib/utils';
+
+type Variant = 'pill' | 'underline' | 'segment';
+
+type Ctx = {
+ value: string;
+ setValue: (v: string) => void;
+ layoutId: string;
+ variant: Variant;
+};
+
+const TabsCtx = createContext(null);
+
+function useTabs() {
+ const ctx = useContext(TabsCtx);
+ if (!ctx) throw new Error('Tabs.* must be used inside ');
+ return ctx;
+}
+
+// Weighty spring for the active-tab indicator: a touch of overshoot so it
+// settles with life instead of snapping.
+const transition: Transition = {
+ type: 'spring',
+ stiffness: 170,
+ damping: 24,
+ mass: 1.2,
+};
+
+export function Tabs({
+ defaultValue,
+ value,
+ onValueChange,
+ variant = 'pill',
+ children,
+ className,
+}: {
+ defaultValue?: string;
+ value?: string;
+ onValueChange?: (v: string) => void;
+ variant?: Variant;
+ children: ReactNode;
+ className?: string;
+}) {
+ const [internal, setInternal] = useState(defaultValue ?? '');
+ const layoutId = useId();
+ const reduce = useReducedMotion();
+ const controlled = value !== undefined;
+ const current = controlled ? value : internal;
+ const setValue = (v: string) => {
+ if (!controlled) setInternal(v);
+ onValueChange?.(v);
+ };
+ return (
+
+
+ {/* layoutRoot: the indicator's layoutId measures in page coordinates, so
+ inside fixed/scrolled containers it would replay scroll offsets as
+ movement. The pill only ever travels within the list, so scoping
+ projection to the Tabs wrapper is always correct. */}
+
+ {children}
+
+
+
+ );
+}
+
+const listClasses: Record = {
+ pill: 'inline-flex items-center gap-1 rounded-full bg-card p-1',
+ underline: 'inline-flex items-center gap-1 border-b border-border',
+ segment: 'inline-flex items-center gap-0 rounded-lg bg-card p-0.5',
+};
+
+export function TabsList({ children, className }: { children: ReactNode; className?: string }) {
+ const { variant } = useTabs();
+ return (
+
+ {children}
+
+ );
+}
+
+export function TabsTrigger({
+ value,
+ children,
+ className,
+ indicatorClassName,
+}: {
+ value: string;
+ children: ReactNode;
+ className?: string;
+ indicatorClassName?: string;
+}) {
+ const { value: current, setValue, layoutId, variant } = useTabs();
+ const active = current === value;
+
+ if (variant === 'underline') {
+ return (
+ setValue(value)}
+ className={cn(
+ 'relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center',
+ active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
+ className,
+ )}
+ >
+ {children}
+ {active ? (
+
+ ) : null}
+
+ );
+ }
+
+ // Pill + Segment use the same trick: a max-contrast pill slides via layoutId,
+ // text uses `mix-blend-exclusion` so it inverts dynamically against the moving bg.
+ const radius = variant === 'pill' ? 'rounded-full' : 'rounded-md';
+
+ return (
+
+ {active ? (
+
+ ) : null}
+ setValue(value)}
+ className={cn(
+ 'relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium capitalize transition-colors outline-none',
+ active ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
+ radius,
+ className,
+ )}
+ >
+ {children}
+
+
+ );
+}
+
+export function TabsContent({
+ value,
+ children,
+ className,
+}: {
+ value: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ const { value: current } = useTabs();
+ const reduce = useReducedMotion();
+ const active = current === value;
+ // Inactive panels stay mounted but hidden, so their content (e.g. source
+ // code) is present in the server-rendered HTML for crawlers and assistive
+ // tech, instead of being dropped from the DOM.
+ if (!active) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/client/components/ui/confirm-dialog.tsx b/src/client/components/ui/confirm-dialog.tsx
new file mode 100644
index 0000000..260be9c
--- /dev/null
+++ b/src/client/components/ui/confirm-dialog.tsx
@@ -0,0 +1,59 @@
+import * as Dialog from '@radix-ui/react-dialog';
+import { AlertTriangle } from 'lucide-react';
+import { Button } from '@client/components/ui/button';
+
+interface ConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ onConfirm: () => void;
+}
+
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmLabel = 'Continue',
+ cancelLabel = 'Cancel',
+ onConfirm,
+}: ConfirmDialogProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+
+ {cancelLabel}
+
+ {
+ onConfirm();
+ onOpenChange(false);
+ }}
+ >
+ {confirmLabel}
+
+
+
+
+
+ );
+}
diff --git a/src/client/components/ui/dropdown-menu.tsx b/src/client/components/ui/dropdown-menu.tsx
index b10afc0..f56b6b1 100644
--- a/src/client/components/ui/dropdown-menu.tsx
+++ b/src/client/components/ui/dropdown-menu.tsx
@@ -1,209 +1,299 @@
-import * as React from 'react';
-import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
-import { Check, ChevronRight, Circle } from 'lucide-react';
+import { Slot } from '@radix-ui/react-slot';
+import { AnimatePresence, motion, useReducedMotion, type Transition } from 'motion/react';
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type MouseEvent,
+ type ReactNode,
+ type RefCallback,
+} from 'react';
+import { createPortal } from 'react-dom';
import { cn } from '@client/lib/utils';
-const DropdownMenu = DropdownMenuPrimitive.Root;
-
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-
-const DropdownMenuGroup = DropdownMenuPrimitive.Group;
-
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
-
-const DropdownMenuSub = DropdownMenuPrimitive.Sub;
-
-const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
-
-const menuContentClass =
- 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white p-1 text-zinc-900 shadow-sm shadow-black/[0.02] dark:border-border dark:bg-popover dark:text-popover-foreground dark:shadow-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200';
-
-const menuItemClass =
- 'relative flex cursor-default select-none items-center rounded-md text-sm outline-none transition-colors hover:bg-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900 data-[highlighted]:bg-zinc-200 data-[highlighted]:text-zinc-900 dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground dark:data-[highlighted]:bg-primary/[0.12] dark:data-[highlighted]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50';
-
-const DropdownMenuSubTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, children, ...props }, ref) => (
-
- {children}
-
-
-));
-DropdownMenuSubTrigger.displayName =
- DropdownMenuPrimitive.SubTrigger.displayName;
-
-const DropdownMenuSubContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DropdownMenuSubContent.displayName =
- DropdownMenuPrimitive.SubContent.displayName;
-
-const DropdownMenuContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, ...props }, ref) => (
-
- void;
+ triggerRef: React.RefObject;
+ panelRef: React.RefObject;
+ contentId: string;
+}
+
+const DropdownMenuContext = createContext(null);
+
+function useMenuCtx(component: string) {
+ const ctx = useContext(DropdownMenuContext);
+ if (!ctx) throw new Error(`${component} must be used within `);
+ return ctx;
+}
+
+export function DropdownMenu({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const panelRef = useRef(null);
+ const contentId = `${useId()}-menu`;
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
+ const onPointer = (e: PointerEvent) => {
+ const target = e.target as Node;
+ if (triggerRef.current?.contains(target)) return;
+ if (panelRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+ window.addEventListener('keydown', onKey);
+ window.addEventListener('pointerdown', onPointer);
+ return () => {
+ window.removeEventListener('keydown', onKey);
+ window.removeEventListener('pointerdown', onPointer);
+ };
+ }, [open]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function DropdownMenuTrigger({
+ asChild,
+ children,
+}: {
+ asChild?: boolean;
+ children: ReactNode;
+}) {
+ const ctx = useMenuCtx('DropdownMenuTrigger');
+ const Comp = asChild ? Slot : 'button';
+ const setRef: RefCallback = (node) => {
+ ctx.triggerRef.current = node;
+ };
+
+ return (
+ ctx.setOpen(!ctx.open)}
+ >
+ {children}
+
+ );
+}
+
+export interface DropdownMenuContentProps {
+ side?: Side;
+ align?: Align;
+ sideOffset?: number;
+ alignOffset?: number;
+ className?: string;
+ children: ReactNode;
+}
+
+const VIEWPORT_PADDING = 8;
+
+const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
+
+export function DropdownMenuContent({
+ side = 'bottom',
+ align = 'start',
+ sideOffset = 4,
+ alignOffset = 0,
+ className,
+ children,
+}: DropdownMenuContentProps) {
+ const ctx = useMenuCtx('DropdownMenuContent');
+ const reduce = useReducedMotion() ?? false;
+ const [style, setStyle] = useState({ position: 'fixed', visibility: 'hidden' });
+
+ const setRefs: RefCallback = (node) => {
+ ctx.panelRef.current = node;
+ };
+
+ const getItems = () =>
+ Array.from(
+ ctx.panelRef.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"]):not([disabled])') ?? [],
+ );
+
+ // Move focus into the menu on open and restore it to the trigger on close, so
+ // keyboard users land on the first item and return to where they were.
+ const wasOpen = useRef(false);
+ useEffect(() => {
+ if (ctx.open) {
+ wasOpen.current = true;
+ const id = requestAnimationFrame(() => getItems()[0]?.focus());
+ return () => cancelAnimationFrame(id);
+ }
+ if (wasOpen.current) {
+ wasOpen.current = false;
+ // Only pull focus back to the trigger if it isn't already somewhere
+ // deliberate (e.g. an outside click that landed on another control).
+ const active = document.activeElement;
+ if (!active || active === document.body || ctx.panelRef.current?.contains(active)) {
+ ctx.triggerRef.current?.focus();
+ }
+ }
+ }, [ctx.open]);
+
+ useLayoutEffect(() => {
+ if (!ctx.open) return;
+ const trigger = ctx.triggerRef.current;
+ if (!trigger) return;
+ const update = () => {
+ const r = trigger.getBoundingClientRect();
+ const panel = ctx.panelRef.current;
+ const menuW = panel?.offsetWidth ?? 0;
+ const menuH = panel?.offsetHeight ?? 0;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ let top: number;
+ let left: number;
+
+ if (side === 'top' || side === 'bottom') {
+ top = side === 'bottom' ? r.bottom + sideOffset : r.top - sideOffset - menuH;
+ left = align === 'start' ? r.left + alignOffset : r.right - menuW - alignOffset;
+ // Flip to the other side if the preferred one overflows and the opposite fits.
+ if (side === 'bottom' && top + menuH > vh - VIEWPORT_PADDING && r.top - sideOffset - menuH >= VIEWPORT_PADDING) {
+ top = r.top - sideOffset - menuH;
+ } else if (side === 'top' && top < VIEWPORT_PADDING && r.bottom + sideOffset + menuH <= vh - VIEWPORT_PADDING) {
+ top = r.bottom + sideOffset;
+ }
+ } else {
+ left = side === 'right' ? r.right + sideOffset : r.left - sideOffset - menuW;
+ top = align === 'start' ? r.top + alignOffset : r.bottom - menuH - alignOffset;
+ if (side === 'right' && left + menuW > vw - VIEWPORT_PADDING && r.left - sideOffset - menuW >= VIEWPORT_PADDING) {
+ left = r.left - sideOffset - menuW;
+ } else if (side === 'left' && left < VIEWPORT_PADDING && r.right + sideOffset + menuW <= vw - VIEWPORT_PADDING) {
+ left = r.right + sideOffset;
+ }
+ }
+
+ // Keep the panel within the viewport regardless of side/align.
+ top = clamp(top, VIEWPORT_PADDING, Math.max(VIEWPORT_PADDING, vh - menuH - VIEWPORT_PADDING));
+ left = clamp(left, VIEWPORT_PADDING, Math.max(VIEWPORT_PADDING, vw - menuW - VIEWPORT_PADDING));
+
+ setStyle({ position: 'fixed', top, left, visibility: 'visible' });
+ };
+ update();
+ window.addEventListener('scroll', update, true);
+ window.addEventListener('resize', update);
+ return () => {
+ window.removeEventListener('scroll', update, true);
+ window.removeEventListener('resize', update);
+ };
+ }, [ctx.open, ctx.triggerRef, side, align, sideOffset, alignOffset]);
+
+ const onKeyDown = (e: ReactKeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ ctx.setOpen(false);
+ return;
+ }
+ const items = getItems();
+ if (items.length === 0) return;
+ const current = items.indexOf(document.activeElement as HTMLElement);
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ items[(current + 1 + items.length) % items.length].focus();
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ items[(current - 1 + items.length) % items.length].focus();
+ } else if (e.key === 'Home') {
+ e.preventDefault();
+ items[0].focus();
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ items[items.length - 1].focus();
+ }
+ };
+
+ const originX = side === 'top' || side === 'bottom' ? (align === 'start' ? 'left' : 'right') : side === 'right' ? 'left' : 'right';
+ const originY = side === 'bottom' ? 'top' : side === 'top' ? 'bottom' : align === 'start' ? 'top' : 'bottom';
+ const slideDistance = 6;
+ const hiddenOffset =
+ side === 'bottom'
+ ? { y: -slideDistance }
+ : side === 'top'
+ ? { y: slideDistance }
+ : side === 'right'
+ ? { x: -slideDistance }
+ : { x: slideDistance };
+
+ const transition: Transition = reduce
+ ? { duration: 0.1 }
+ : { type: 'spring', duration: 0.35, bounce: 0.15 };
+
+ return createPortal(
+ // AnimatePresence unmounts the menu after its exit animation, so the closed
+ // menu leaves no invisible, focusable items in the tab order.
+
+ {ctx.open && (
+
+ {children}
+
+ )}
+ ,
+ document.body,
+ );
+}
+
+export interface DropdownMenuItemProps {
+ asChild?: boolean;
+ className?: string;
+ onClick?: (e: MouseEvent) => void;
+ children: ReactNode;
+}
+
+export function DropdownMenuItem({ asChild, className, onClick, children }: DropdownMenuItemProps) {
+ const ctx = useMenuCtx('DropdownMenuItem');
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+ ) => {
+ onClick?.(e);
+ ctx.setOpen(false);
+ }}
className={cn(
- menuContentClass,
- 'data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 ease-out',
+ 'relative flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors',
+ 'hover:bg-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900',
+ 'dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground',
className,
)}
- {...props}
- />
-
-));
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
-
-const DropdownMenuItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
-
-));
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
-
-const DropdownMenuCheckboxItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, checked, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-));
-DropdownMenuCheckboxItem.displayName =
- DropdownMenuPrimitive.CheckboxItem.displayName;
-
-const DropdownMenuRadioItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-));
-DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
-
-const DropdownMenuLabel = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
-
-));
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
-
-const DropdownMenuSeparator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
-
-const DropdownMenuShortcut = ({
- className,
- ...props
-}: React.HTMLAttributes) => {
- return (
-
+ >
+ {children}
+
);
-};
-DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
-
-export {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuCheckboxItem,
- DropdownMenuRadioItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuGroup,
- DropdownMenuPortal,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuRadioGroup,
-};
+}
+
+export function DropdownMenuSeparator({ className }: { className?: string }) {
+ return
;
+}
diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx
index 0f419aa..e6a9173 100644
--- a/src/client/components/ui/select.tsx
+++ b/src/client/components/ui/select.tsx
@@ -1,13 +1,33 @@
-import { ChevronDown } from 'lucide-react';
-import type { CSSProperties, ReactNode } from 'react';
-import { cn } from '@client/lib/utils';
+import { Check, ChevronDown } from 'lucide-react';
+import { motion, type Transition, useReducedMotion, type Variants } from 'motion/react';
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from './dropdown-menu';
-import { Button } from './button';
+ type CSSProperties,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type ReactNode,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { cn } from '@client/lib/utils';
+import { EASE_OUT } from '@client/lib/ease';
+
+// Spring with bounce powers the unfold/separation; per-property timings in the
+// content choreograph it. See the `animate`/`transition` props on the panel below.
+const CHEVRON_TRANSITION: Transition = { type: 'spring', duration: 0.4, bounce: 0.3 };
+
+const LIST_VARIANTS: Variants = {
+ hidden: {},
+ show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },
+};
+const ITEM_VARIANTS: Variants = {
+ hidden: { opacity: 0, y: -6, filter: 'blur(3px)' },
+ show: { opacity: 1, y: 0, filter: 'blur(0px)' },
+};
+
+type Placement = 'bottom' | 'top';
interface SelectOption {
value: string;
@@ -26,9 +46,9 @@ interface SelectProps {
leadingIcon?: ReactNode;
/**
* 'page' — trigger sits on the gray page background (e.g. "Last 30 days").
- * Dropdown gets white bg so it lifts off the page.
- * 'card' — trigger sits inside a white card (e.g. "All statuses").
- * Dropdown gets zinc-50 bg so it's distinguishable from the card.
+ * Dropdown gets card bg so it lifts off the page.
+ * 'card' — trigger sits inside a card.
+ * Dropdown gets muted bg so it's distinguishable from the card.
* Defaults to 'page'.
*/
variant?: 'page' | 'card';
@@ -46,8 +66,161 @@ export function Select({
leadingIcon,
variant = 'page',
}: SelectProps) {
+ const reduce = useReducedMotion() ?? false;
+ const baseId = useId();
+ const triggerId = `${baseId}-trigger`;
+ const listId = `${baseId}-list`;
+ const triggerRef = useRef(null);
+ const panelRef = useRef(null);
+ const innerRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [placement, setPlacement] = useState('bottom');
+ const [height, setHeight] = useState(0);
+ const [rect, setRect] = useState<{ left: number; width: number; top: number; bottom: number } | null>(null);
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
+ const optionRefs = useRef>([]);
+
const selectedOption = options.find((opt) => opt.value === value);
+ // Move the active-descendant highlight onto the current selection (or the first option)
+ // each time the listbox opens, so arrow-key navigation always starts from a sane position.
+ useEffect(() => {
+ if (!open) return;
+ const idx = options.findIndex((opt) => opt.value === value);
+ setHighlightedIndex(idx >= 0 ? idx : 0);
+ }, [open, options, value]);
+
+ useEffect(() => {
+ if (!open) return;
+ optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' });
+ }, [open, highlightedIndex]);
+
+ // close on outside pointer / escape
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
+ const onPointer = (e: PointerEvent) => {
+ const target = e.target as Node;
+ if (triggerRef.current?.contains(target)) return;
+ if (panelRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+ window.addEventListener('keydown', onKey);
+ window.addEventListener('pointerdown', onPointer);
+ return () => {
+ window.removeEventListener('keydown', onKey);
+ window.removeEventListener('pointerdown', onPointer);
+ };
+ }, [open]);
+
+ useLayoutEffect(() => {
+ const node = innerRef.current;
+ if (!node) return;
+ const measure = () => setHeight(node.offsetHeight);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
+
+ // Track the trigger's viewport position so the portaled panel can follow it,
+ // and flip upward when there isn't room below and there's more above. Scroll/resize
+ // fire far more often than the display repaints, so batch updates to at most once per
+ // animation frame instead of re-rendering on every raw event.
+ useLayoutEffect(() => {
+ if (!open) return;
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ let frame: number | null = null;
+ const update = () => {
+ frame = null;
+ const r = trigger.getBoundingClientRect();
+ setRect({ left: r.left, width: r.width, top: r.top, bottom: r.bottom });
+ const h = innerRef.current?.offsetHeight ?? 0;
+ const below = window.innerHeight - r.bottom;
+ const above = r.top;
+ setPlacement(below < h + 16 && above > below ? 'top' : 'bottom');
+ };
+ const scheduleUpdate = () => {
+ if (frame !== null) return;
+ frame = requestAnimationFrame(update);
+ };
+ update();
+ window.addEventListener('scroll', scheduleUpdate, true);
+ window.addEventListener('resize', scheduleUpdate);
+ return () => {
+ if (frame !== null) cancelAnimationFrame(frame);
+ window.removeEventListener('scroll', scheduleUpdate, true);
+ window.removeEventListener('resize', scheduleUpdate);
+ };
+ }, [open]);
+
+ const moveHighlight = (next: number) => {
+ setHighlightedIndex(Math.min(Math.max(next, 0), options.length - 1));
+ };
+
+ const onTriggerKeyDown = (e: ReactKeyboardEvent) => {
+ if (!open) {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setOpen(true);
+ }
+ return;
+ }
+ if (options.length === 0) return;
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ moveHighlight(highlightedIndex + 1);
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ moveHighlight(highlightedIndex - 1);
+ break;
+ case 'Home':
+ e.preventDefault();
+ moveHighlight(0);
+ break;
+ case 'End':
+ e.preventDefault();
+ moveHighlight(options.length - 1);
+ break;
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ onValueChange(options[highlightedIndex].value);
+ setOpen(false);
+ break;
+ case 'Tab':
+ setOpen(false);
+ break;
+ default:
+ break;
+ }
+ };
+
+ const isTop = placement === 'top';
+
+ // Gooey: the edge facing the panel snaps flat (panel attached) then rounds
+ // back once the panel pulls away — the two pinch apart.
+ const kf = open ? [0, 0, 12] : [12, 0, 12];
+ const kfT: Transition = reduce
+ ? { duration: 0 }
+ : open
+ ? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }
+ : { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };
+ const flatT: Transition = { duration: 0 };
+
+ const nearGap = open ? 8 : 0;
+ const nearRadius = open ? 12 : 0;
+ const gapT: Transition = open
+ ? { type: 'spring', duration: 0.6, bounce: 0.5, delay: 0.12 }
+ : { type: 'spring', duration: 0.3, bounce: 0.1 };
+ const radiusT: Transition = open
+ ? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }
+ : { duration: 0.16, ease: EASE_OUT };
+ const instant: Transition = { duration: 0 };
+
return (
{label && (
@@ -55,50 +228,157 @@ export function Select({
{label}
)}
-
-
-
-
- {leadingIcon && (
-
- {leadingIcon}
-
- )}
-
- {selectedOption ? selectedOption.label : placeholder}
-
+
+ setOpen((v) => !v)}
+ onKeyDown={onTriggerKeyDown}
+ initial={false}
+ animate={{
+ borderTopLeftRadius: isTop ? kf : 12,
+ borderTopRightRadius: isTop ? kf : 12,
+ borderBottomLeftRadius: isTop ? 12 : kf,
+ borderBottomRightRadius: isTop ? 12 : kf,
+ }}
+ transition={{
+ borderTopLeftRadius: isTop ? kfT : flatT,
+ borderTopRightRadius: isTop ? kfT : flatT,
+ borderBottomLeftRadius: isTop ? flatT : kfT,
+ borderBottomRightRadius: isTop ? flatT : kfT,
+ }}
+ style={triggerStyle}
+ className={cn(
+ 'relative z-10 flex h-9 w-full items-center justify-between gap-2 border border-border px-3 py-2 text-sm font-normal text-foreground outline-none transition-colors',
+ variant === 'page' ? 'bg-card' : 'bg-muted/50',
+ 'hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
+ !selectedOption && 'text-muted-foreground',
+ triggerClassName,
+ )}
+ >
+
+ {leadingIcon && {leadingIcon} }
+
+ {selectedOption ? selectedOption.label : placeholder}
-
-
-
-
+
+
+
+
+
+
+
+ {/* Portaled to so the panel always renders above cards, tables, and
+ other stacking contexts — it can't be clipped/hidden by an ancestor. */}
+ {createPortal(
+ round; far corners stay rounded
+ borderTopLeftRadius: isTop ? 12 : nearRadius,
+ borderTopRightRadius: isTop ? 12 : nearRadius,
+ borderBottomLeftRadius: isTop ? nearRadius : 12,
+ borderBottomRightRadius: isTop ? nearRadius : 12,
+ }
+ }
+ transition={
+ reduce
+ ? { duration: 0.12 }
+ : {
+ opacity: open ? { duration: 0.18 } : { duration: 0.16, delay: 0.12 },
+ height: open
+ ? { type: 'spring', duration: 0.42, bounce: 0.14 }
+ : { duration: 0.26, ease: EASE_OUT, delay: 0.14 },
+ marginTop: isTop ? instant : gapT,
+ marginBottom: isTop ? gapT : instant,
+ borderTopLeftRadius: isTop ? instant : radiusT,
+ borderTopRightRadius: isTop ? instant : radiusT,
+ borderBottomLeftRadius: isTop ? radiusT : instant,
+ borderBottomRightRadius: isTop ? radiusT : instant,
+ }
+ }
+ style={{
+ position: 'fixed',
+ left: rect?.left ?? 0,
+ width: rect?.width ?? 0,
+ top: isTop ? undefined : (rect?.bottom ?? 0),
+ bottom: isTop ? window.innerHeight - (rect?.top ?? 0) : undefined,
+ transformOrigin: isTop ? 'bottom' : 'top',
+ overflow: 'hidden',
+ pointerEvents: open ? 'auto' : 'none',
+ }}
+ // flush against the trigger, then separates into its own rounded pill;
+ // sits above or below depending on available space
+ className="z-50 border border-border bg-popover shadow-lg shadow-black/[0.03] dark:shadow-black/40"
>
- {options.map((option) => (
- onValueChange(option.value)}
- className={cn(
- 'cursor-pointer whitespace-normal break-words py-2',
- value === option.value &&
- 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary',
- )}
- >
- {option.label}
-
- ))}
-
-
+
+ {options.map((option, index) => {
+ const selected = option.value === value;
+ const highlighted = index === highlightedIndex;
+ return (
+
+ {
+ optionRefs.current[index] = node;
+ }}
+ id={`${listId}-option-${index}`}
+ type="button"
+ role="option"
+ aria-selected={selected}
+ tabIndex={-1}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ onClick={() => {
+ onValueChange(option.value);
+ setOpen(false);
+ }}
+ className={cn(
+ 'flex w-full items-center justify-between gap-2 whitespace-normal break-words rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors',
+ selected
+ ? 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary'
+ : 'text-foreground/90 hover:bg-muted hover:text-foreground focus-visible:bg-muted',
+ highlighted && !selected && 'bg-muted text-foreground',
+ )}
+ >
+ {option.label}
+ {selected ? : null}
+
+
+ );
+ })}
+
+ ,
+ document.body,
+ )}
);
}
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts
index d218b3c..dfd7ef2 100644
--- a/src/client/lib/api.ts
+++ b/src/client/lib/api.ts
@@ -10,7 +10,7 @@ import type {
SyncReposResponse,
UpdatesEmailResponse,
} from '@shared/api';
-import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema';
+import type { LlmApiFormat, LlmProvider, RepoConfig, ReviewSettings } from '@shared/schema';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -23,7 +23,6 @@ function pathSegment(value: string) {
}
type QueryValue = string | number | boolean | null | undefined;
-export type ModelConfigPayload = Pick;
export type ProviderPayload = {
name: string;
apiFormat: LlmApiFormat;
@@ -202,17 +201,6 @@ export const api = {
method: 'POST',
});
},
- updateModelConfig(id: string, config: ModelConfigPayload) {
- return request<{ ok: boolean; config: ModelConfig }>(`/api/models/${pathSegment(id)}`, {
- method: 'PATCH',
- body: JSON.stringify(config),
- });
- },
- deleteModelConfig(id: string) {
- return request<{ ok: boolean }>(`/api/models/${pathSegment(id)}`, {
- method: 'DELETE',
- });
- },
createProvider(config: ProviderPayload) {
return request<{ provider: LlmProvider }>('/api/models/providers', {
method: 'POST',
@@ -230,11 +218,6 @@ export const api = {
method: 'DELETE',
});
},
- testModelConfig(id: string) {
- return request<{ ok: boolean; modelUsed: string; provider: string; inputTokens: number; outputTokens: number }>(`/api/models/${pathSegment(id)}/test`, {
- method: 'POST',
- });
- },
getGlobalConfig() {
return request<{ config: RepoConfig['model'] }>('/api/models/global');
},
@@ -244,4 +227,13 @@ export const api = {
body: JSON.stringify(config),
});
},
+ getReviewSettings() {
+ return request<{ settings: ReviewSettings }>('/api/settings');
+ },
+ updateReviewSettings(settings: ReviewSettings) {
+ return request<{ ok: boolean; settings: ReviewSettings }>('/api/settings', {
+ method: 'PATCH',
+ body: JSON.stringify(settings),
+ });
+ },
};
diff --git a/src/client/lib/ease.ts b/src/client/lib/ease.ts
new file mode 100644
index 0000000..8abd8d1
--- /dev/null
+++ b/src/client/lib/ease.ts
@@ -0,0 +1 @@
+export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 0bb5880..89016b8 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -19,6 +19,7 @@ import './app.css';
import { ThemeProvider } from './lib/theme';
import { useIsDarkMode } from './hooks/use-is-dark-mode';
+import { SmoothScroll } from './components/motion/smooth-scroll';
function ToasterWrapper() {
const isDark = useIsDarkMode();
@@ -26,7 +27,6 @@ function ToasterWrapper() {
-
-
+
+
+
+
,
);
diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx
index ea17c9f..82c220f 100644
--- a/src/client/pages/settings.tsx
+++ b/src/client/pages/settings.tsx
@@ -9,24 +9,23 @@ import { Skeleton } from '@client/components/shared/skeleton';
import { Input } from '@client/components/ui/input';
import { Select } from '@client/components/ui/select';
import { Switch } from '@client/components/ui/switch';
+import { SteppedSlider } from '@client/components/motion/stepped-slider';
+import { ConfirmDialog } from '@client/components/ui/confirm-dialog';
import {
- Cpu,
Save,
ShieldAlert,
Layers,
RefreshCw,
- PlugZap,
Plus,
Trash2,
- Search,
ChevronDown,
ChevronRight,
X,
- Tag,
ExternalLink,
- GitCommit,
} from 'lucide-react';
-import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema';
+import { Badge } from '@client/components/ui/badge';
+import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig, ReviewSettings } from '@shared/schema';
+import { REVIEW_CONCURRENCY_LIMITS, reviewMaxCommentsOptions, type ReviewConcurrencyLevel } from '@shared/schema';
import type { ModelConfigsResponse } from '@shared/api';
import {
ModelRouteEditor,
@@ -57,6 +56,24 @@ const PROVIDER_PRESETS = [
const FIXED_PROVIDER_NAMES = new Set(['OpenAI', 'OpenRouter', 'Anthropic', 'Google', 'Cloudflare']);
+const CONCURRENCY_LEVEL_ORDER: ReviewConcurrencyLevel[] = ['low', 'medium', 'high', 'max'];
+const CONCURRENCY_LEVEL_LABEL: Record = {
+ low: 'Low',
+ medium: 'Medium',
+ high: 'High',
+ max: 'Max',
+};
+const CONCURRENCY_STEPS = CONCURRENCY_LEVEL_ORDER.map(level => ({
+ value: REVIEW_CONCURRENCY_LIMITS[level],
+ label: CONCURRENCY_LEVEL_LABEL[level],
+}));
+const CONCURRENCY_VALUE_TO_LEVEL: Record = Object.fromEntries(
+ CONCURRENCY_LEVEL_ORDER.map(level => [REVIEW_CONCURRENCY_LIMITS[level], level]),
+) as Record;
+const CONCURRENCY_MAX_VALUE = REVIEW_CONCURRENCY_LIMITS.max;
+const MAX_COMMENTS_STEPS = reviewMaxCommentsOptions.map(n => ({ value: n, label: String(n) }));
+const MAX_COMMENTS_CEILING = reviewMaxCommentsOptions[reviewMaxCommentsOptions.length - 1];
+
type ProviderDraft = LlmProvider & { apiKey: string };
type NewProviderDraft = {
preset: string;
@@ -66,15 +83,6 @@ type NewProviderDraft = {
apiKey: string;
enabled: boolean;
};
-type NewModelDraft = {
- modelId: string;
- providerId: string;
- modelName: string;
- rpm: number | null;
- tpm: number | null;
- rpd: number | null;
-};
-
type SyncError = { providerId: string; providerName: string; error: string };
type GlobalConfigInput = RepoConfig['model'] | Partial | null | undefined;
@@ -113,38 +121,6 @@ function routeEqual(a: ModelRouteConfig | null, b: ModelRouteConfig | null) {
);
}
-function configEqual(a?: ModelConfig, b?: ModelConfig) {
- return Boolean(
- a && b &&
- a.rpm === b.rpm &&
- a.rpd === b.rpd &&
- a.tpm === b.tpm &&
- a.providerId === b.providerId &&
- a.modelName === b.modelName,
- );
-}
-
-function modelPayload(config: ModelConfig) {
- return {
- providerId: config.providerId,
- modelName: config.modelName,
- rpm: config.rpm,
- rpd: config.rpd,
- tpm: config.tpm,
- };
-}
-
-function parseOptionalLimit(value: string) {
- const trimmed = value.trim();
- if (!trimmed) return null;
- const parsed = Number(trimmed);
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
-}
-
-function formatOptionalLimit(value: number | null) {
- return value === null ? 'Unset' : value.toLocaleString();
-}
-
function providerToDraft(provider: LlmProvider): ProviderDraft {
return { ...provider, apiKey: '' };
}
@@ -204,7 +180,7 @@ function SectionCard({
{icon && (
-
+
{icon}
)}
@@ -221,9 +197,9 @@ function SectionCard({
}
/* ─── Field label ─────────────────────────────────────────────────────────── */
-function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
+function FieldLabel({ htmlFor, id, children }: { htmlFor: string; id?: string; children: React.ReactNode }) {
return (
-
+
{children}
);
@@ -238,8 +214,6 @@ function StatPill({ label }: { label: string }) {
);
}
-
-
export function SettingsPage() {
const [providers, setProviders] = useState([]);
const [savedProviders, setSavedProviders] = useState([]);
@@ -247,6 +221,9 @@ export function SettingsPage() {
const [savedConfigs, setSavedConfigs] = useState([]);
const [globalConfig, setGlobalConfig] = useState(null);
const [savedGlobalConfig, setSavedGlobalConfig] = useState(null);
+ const [reviewSettings, setReviewSettings] = useState(null);
+ const [savedReviewSettings, setSavedReviewSettings] = useState(null);
+ const [pendingConfirm, setPendingConfirm] = useState<{ field: 'concurrency' | 'comments'; value: number } | null>(null);
const [newProvider, setNewProvider] = useState({
preset: 'custom-openai',
name: 'Custom OpenAI',
@@ -255,14 +232,6 @@ export function SettingsPage() {
apiKey: '',
enabled: true,
});
- const [newModel, setNewModel] = useState({
- modelId: '',
- providerId: '',
- modelName: '',
- rpm: null,
- tpm: null,
- rpd: null,
- });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(null);
const [error, setError] = useState(null);
@@ -271,10 +240,6 @@ export function SettingsPage() {
const [catalogRefreshedOnce, setCatalogRefreshedOnce] = useState(false);
const [addingProvider, setAddingProvider] = useState(false);
const [expandedProviderId, setExpandedProviderId] = useState(null);
- const [modelSearch, setModelSearch] = useState('');
- const [modelProviderFilter, setModelProviderFilter] = useState('all');
- const [expandedModelId, setExpandedModelId] = useState(null);
- const [addingModel, setAddingModel] = useState(false);
const providerOptions: ProviderOption[] = useMemo(
() => providers.map(provider => ({ value: provider.id, label: provider.name })),
@@ -311,60 +276,50 @@ export function SettingsPage() {
[globalConfig, savedGlobalConfig],
);
- const dirtyConfigs = useMemo(
- () => configs.filter(cfg => !configEqual(cfg, savedConfigs.find(saved => saved.modelId === cfg.modelId))),
- [configs, savedConfigs],
- );
-
- const modelProviderFilterOptions = useMemo(
- () => [
- { value: 'all', label: 'All providers' },
- ...providerOptions,
- ],
- [providerOptions],
- );
-
- const filteredConfigs = useMemo(() => {
- const query = modelSearch.trim().toLowerCase();
- return configs.filter((config) => {
- const matchesProvider = modelProviderFilter === 'all' || config.providerId === modelProviderFilter;
- const matchesQuery = !query ||
- config.modelId.toLowerCase().includes(query) ||
- config.modelName.toLowerCase().includes(query) ||
- config.providerName.toLowerCase().includes(query);
- return matchesProvider && matchesQuery;
- });
- }, [configs, modelProviderFilter, modelSearch]);
-
const applyModelConfigResponse = (modelsRes: ModelConfigsResponse) => {
setProviders(modelsRes.providers.map(providerToDraft));
setSavedProviders(modelsRes.providers);
setConfigs(modelsRes.configs);
setSavedConfigs(modelsRes.configs);
setSyncErrors(modelsRes.syncErrors ?? []);
- setNewModel(current => ({
- ...current,
- providerId: current.providerId || modelsRes.providers[0]?.id || '',
- }));
};
const refreshModelCatalog = async ({ quiet = false }: { quiet?: boolean } = {}) => {
if (catalogRefreshing) return;
setCatalogRefreshing(true);
setSyncErrors([]);
- const tid = quiet ? null : toast.loading('Refreshing model catalog...');
+ const tid = quiet ? null : toast.loading('Syncing providers and models...');
try {
+ let savedProviderCount = 0;
+ let failedProviderCount = 0;
+ if (!quiet) {
+ const dirtyProviders = providers.filter(
+ provider => providerDraftDirty(provider, savedProviders.find(saved => saved.id === provider.id)),
+ );
+ if (dirtyProviders.length > 0) {
+ const results = await Promise.all(dirtyProviders.map(provider => persistProvider(provider, { quiet: true })));
+ savedProviderCount = results.filter(Boolean).length;
+ failedProviderCount = results.length - savedProviderCount;
+ }
+ }
+
const modelsRes = await api.refreshModelCatalog();
applyModelConfigResponse(modelsRes);
setCatalogRefreshedOnce(true);
+
if (!quiet) {
- const failed = modelsRes.syncErrors?.length ?? 0;
- toast.success('Model catalog refreshed', {
- id: tid ?? undefined,
- description: failed > 0
- ? `${failed} provider${failed === 1 ? '' : 's'} reported an error.`
- : 'Provider model lists are now up to date.',
- });
+ const failedCatalogs = modelsRes.syncErrors?.length ?? 0;
+ const parts: string[] = [];
+ if (savedProviderCount > 0) parts.push(`${savedProviderCount} provider${savedProviderCount === 1 ? '' : 's'} saved`);
+ if (failedProviderCount > 0) parts.push(`${failedProviderCount} provider${failedProviderCount === 1 ? '' : 's'} failed to save`);
+ if (failedCatalogs > 0) parts.push(`${failedCatalogs} provider${failedCatalogs === 1 ? '' : 's'} reported a catalog error`);
+
+ const description = parts.length > 0 ? parts.join(' · ') : 'Providers and model lists are up to date.';
+ if (failedProviderCount > 0 || failedCatalogs > 0) {
+ toast.error('Sync finished with issues', { id: tid ?? undefined, description });
+ } else {
+ toast.success('Sync complete', { id: tid ?? undefined, description });
+ }
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Catalog refresh failed';
@@ -377,14 +332,17 @@ export function SettingsPage() {
const loadConfigs = async () => {
try {
- const [modelsRes, globalRes] = await Promise.all([
+ const [modelsRes, globalRes, reviewSettingsRes] = await Promise.all([
api.getModelConfigs(),
api.getGlobalConfig(),
+ api.getReviewSettings(),
]);
const nextGlobalConfig = normalizeGlobalConfig(globalRes.config);
applyModelConfigResponse(modelsRes);
setGlobalConfig(nextGlobalConfig);
setSavedGlobalConfig(nextGlobalConfig);
+ setReviewSettings(reviewSettingsRes.settings);
+ setSavedReviewSettings(reviewSettingsRes.settings);
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load settings';
@@ -404,14 +362,13 @@ export function SettingsPage() {
return () => { mounted = false; };
}, []);
- const handleGlobalUpdate = async () => {
- if (!globalConfig || !globalDirty) return;
+ const persistGlobalConfig = async (next: ModelRouteConfig) => {
setSaving('global');
setError(null);
const tid = toast.loading('Saving model strategy...');
try {
- await api.updateGlobalConfig(globalConfig);
- setSavedGlobalConfig(globalConfig);
+ await api.updateGlobalConfig(next);
+ setSavedGlobalConfig(next);
toast.success('Global strategy saved', {
id: tid,
description: 'Repositories without a custom strategy will use these settings.',
@@ -425,20 +382,91 @@ export function SettingsPage() {
}
};
+ useEffect(() => {
+ if (!globalConfig || !globalDirty) return;
+ const handle = setTimeout(() => void persistGlobalConfig(globalConfig), 600);
+ return () => clearTimeout(handle);
+ }, [globalConfig, globalDirty]);
+
+ const persistReviewSettings = async (next: ReviewSettings, summary: string) => {
+ setReviewSettings(next);
+ setSaving('review-settings');
+ setError(null);
+ const tid = toast.loading('Saving…');
+ try {
+ await api.updateReviewSettings(next);
+ setSavedReviewSettings(next);
+ toast.success(summary, { id: tid });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Update failed';
+ setReviewSettings(savedReviewSettings);
+ setError(msg);
+ toast.error('Could not save settings', { id: tid, description: msg });
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ const handleConcurrencyChange = (value: number) => {
+ if (!reviewSettings) return;
+ if (value === CONCURRENCY_MAX_VALUE && reviewSettings.concurrencyLevel !== 'max') {
+ setPendingConfirm({ field: 'concurrency', value });
+ return;
+ }
+ const level = CONCURRENCY_VALUE_TO_LEVEL[value];
+ void persistReviewSettings(
+ { ...reviewSettings, concurrencyLevel: level },
+ `Concurrency set to ${CONCURRENCY_LEVEL_LABEL[level]}`,
+ );
+ };
+
+ const handleCommentsChange = (value: number) => {
+ if (!reviewSettings) return;
+ if (value === MAX_COMMENTS_CEILING && reviewSettings.maxComments !== MAX_COMMENTS_CEILING) {
+ setPendingConfirm({ field: 'comments', value });
+ return;
+ }
+ void persistReviewSettings(
+ { ...reviewSettings, maxComments: value as ReviewSettings['maxComments'] },
+ `Comment limit set to ${value}`,
+ );
+ };
+
+ const applyPendingConfirm = () => {
+ if (!pendingConfirm || !reviewSettings) return;
+ if (pendingConfirm.field === 'concurrency') {
+ const level = CONCURRENCY_VALUE_TO_LEVEL[pendingConfirm.value];
+ void persistReviewSettings(
+ { ...reviewSettings, concurrencyLevel: level },
+ `Concurrency set to ${CONCURRENCY_LEVEL_LABEL[level]}`,
+ );
+ } else {
+ void persistReviewSettings(
+ { ...reviewSettings, maxComments: pendingConfirm.value as ReviewSettings['maxComments'] },
+ `Comment limit set to ${pendingConfirm.value}`,
+ );
+ }
+ };
+
const updateProviderDraft = (id: string, updates: Partial) => {
setProviders(current => current.map(provider => provider.id === id ? { ...provider, ...updates } : provider));
};
- const saveProvider = async (provider: ProviderDraft) => {
- if (provider.enabled && !providerHasCredential(provider)) {
- setExpandedProviderId(provider.id);
- toast.error('Add an API key before enabling this provider.');
- return;
+ const persistProvider = async (
+ provider: ProviderDraft,
+ { quiet = false, clearApiKey = false }: { quiet?: boolean; clearApiKey?: boolean } = {},
+ ) => {
+ if (provider.enabled && !clearApiKey && !providerHasCredential(provider)) {
+ if (!quiet) {
+ setExpandedProviderId(provider.id);
+ toast.error('Add an API key before enabling this provider.');
+ }
+ return null;
}
setSaving(`provider:${provider.id}`);
setError(null);
- const tid = toast.loading('Saving provider...');
+ const tid = quiet ? null : toast.loading('Saving provider...');
try {
const payload: ProviderPayload = {
name: provider.name,
@@ -446,29 +474,39 @@ export function SettingsPage() {
baseUrl: provider.baseUrl || null,
enabled: provider.enabled,
};
- if (provider.apiKey !== undefined && providerDraftDirty(provider)) {
- if (provider.apiKey.trim()) {
- payload.apiKey = provider.apiKey.trim();
- } else {
- payload.clearApiKey = true;
- }
+ if (clearApiKey) {
+ payload.clearApiKey = true;
+ } else if (provider.apiKey.trim()) {
+ payload.apiKey = provider.apiKey.trim();
}
const { provider: saved } = await api.updateProvider(provider.id, payload);
setProviders(current => current.map(item => item.id === saved.id ? providerToDraft(saved) : item));
setSavedProviders(current => current.map(item => item.id === saved.id ? saved : item));
- toast.success('Provider saved', { id: tid });
- if (saved.enabled && (saved.hasApiKey || saved.apiFormat === 'cloudflare-workers-ai')) {
- void refreshModelCatalog({ quiet: true });
- }
+ if (!quiet) toast.success('Provider saved', { id: tid ?? undefined });
+ return saved;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Provider update failed';
setError(msg);
- toast.error('Could not save provider', { id: tid, description: msg });
+ toast.error('Could not save provider', { id: tid ?? undefined, description: msg });
+ return null;
} finally {
setSaving(null);
}
};
+ const saveProvider = async (provider: ProviderDraft) => {
+ const saved = await persistProvider(provider);
+ if (saved && saved.enabled && (saved.hasApiKey || saved.apiFormat === 'cloudflare-workers-ai')) {
+ void refreshModelCatalog({ quiet: true });
+ }
+ };
+
+ const clearProviderKey = async (provider: ProviderDraft) => {
+ // A provider can't stay enabled without a key, so drop it to disabled while
+ // clearing (the server rejects an enabled provider with no credential).
+ await persistProvider({ ...provider, apiKey: '', enabled: false }, { clearApiKey: true });
+ };
+
const createProvider = async () => {
if (!newProvider.name.trim() || selectedProviderNameExists) return;
setSaving('provider:new');
@@ -492,7 +530,6 @@ export function SettingsPage() {
apiKey: '',
enabled: true,
});
- setNewModel(current => ({ ...current, providerId: current.providerId || provider.id }));
setAddingProvider(false);
toast.success('Provider created', { id: tid });
if (provider.enabled && (provider.hasApiKey || provider.apiFormat === 'cloudflare-workers-ai')) {
@@ -525,138 +562,6 @@ export function SettingsPage() {
}
};
- const markConfigSaved = (id: string, saved: ModelConfig) => {
- setConfigs(current => current.map(cfg => (cfg.modelId === id ? saved : cfg)));
- setSavedConfigs(current => current.map(cfg => (cfg.modelId === id ? saved : cfg)));
- };
-
- const handleUpdate = async (id: string) => {
- const current = configs.find(c => c.modelId === id);
- if (!current) return;
- setSaving(id);
- setError(null);
- const tid = toast.loading('Updating model...');
- try {
- const { config } = await api.updateModelConfig(id, modelPayload(current));
- markConfigSaved(id, config);
- toast.success('Model updated', { id: tid, description: 'Provider mapping and limits have been saved.' });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Update failed';
- setError(msg);
- toast.error('Model update failed', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const handleSaveAllModels = async () => {
- if (dirtyConfigs.length === 0) return;
- setSaving('models');
- setError(null);
- const tid = toast.loading(`Saving ${dirtyConfigs.length} model change${dirtyConfigs.length === 1 ? '' : 's'}...`);
- try {
- const results = await Promise.allSettled(dirtyConfigs.map(cfg => api.updateModelConfig(cfg.modelId, modelPayload(cfg))));
- const saved = results
- .filter((result): result is PromiseFulfilledResult>> => result.status === 'fulfilled')
- .map(result => result.value);
- const failed = results.length - saved.length;
-
- const savedById = new Map(saved.map(result => [result.config.modelId, result.config]));
- setConfigs(current => current.map(cfg => savedById.get(cfg.modelId) ?? cfg));
- setSavedConfigs(current => current.map(cfg => savedById.get(cfg.modelId) ?? cfg));
-
- if (failed > 0) {
- const msg = `${failed} model update${failed === 1 ? '' : 's'} failed. Saved ${saved.length}.`;
- setError(msg);
- toast.error('Some models were not saved', { id: tid, description: msg });
- } else {
- toast.success('Models saved', { id: tid });
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Update failed';
- setError(msg);
- toast.error('Could not save models', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const createModel = async () => {
- if (!newModel.modelId.trim() || !newModel.providerId || !newModel.modelName.trim()) return;
- setSaving('model:new');
- setError(null);
- const tid = toast.loading('Creating model...');
- try {
- const { config } = await api.updateModelConfig(newModel.modelId.trim(), {
- providerId: newModel.providerId,
- modelName: newModel.modelName.trim(),
- rpm: newModel.rpm,
- tpm: newModel.tpm,
- rpd: newModel.rpd,
- });
- setConfigs(current => [...current.filter(item => item.modelId !== config.modelId), config].sort((a, b) => a.modelId.localeCompare(b.modelId)));
- setSavedConfigs(current => [...current.filter(item => item.modelId !== config.modelId), config].sort((a, b) => a.modelId.localeCompare(b.modelId)));
- setNewModel(current => ({ ...current, modelId: '', modelName: '' }));
- setAddingModel(false);
- toast.success('Model created', { id: tid });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Model creation failed';
- setError(msg);
- toast.error('Could not create model', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const deleteModel = async (id: string) => {
- setSaving(id);
- setError(null);
- const tid = toast.loading('Deleting model...');
- try {
- await api.deleteModelConfig(id);
- setConfigs(current => current.filter(config => config.modelId !== id));
- setSavedConfigs(current => current.filter(config => config.modelId !== id));
- toast.success('Model deleted', { id: tid });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Delete failed';
- setError(msg);
- toast.error('Could not delete model', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const testModel = async (id: string) => {
- setSaving(`test:${id}`);
- setError(null);
- const tid = toast.loading('Testing model connection...');
- try {
- const result = await api.testModelConfig(id);
- toast.success('Connection works', {
- id: tid,
- description: `${result.provider} returned ${result.outputTokens} output tokens.`,
- });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Connection failed';
- setError(msg);
- toast.error('Connection failed', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const updateModel = (id: string, updates: Partial) => {
- setConfigs(current =>
- current.map(cfg =>
- cfg.modelId === id ? { ...cfg, ...updates } : cfg,
- ),
- );
- };
-
- const updateQuota = (id: string, field: 'rpm' | 'rpd' | 'tpm', value: number | null) => {
- updateModel(id, { [field]: value } as Partial);
- };
-
const newProviderReady = newProvider.name.trim().length > 0 &&
newProvider.baseUrl.trim().length > 0 &&
newProvider.apiKey.trim().length > 0 &&
@@ -690,6 +595,73 @@ export function SettingsPage() {
)}
+ {/* ── Review performance ──────────────────────────────────────────────── */}
+
+
+ {!loading && reviewSettings ? (
+ <>
+
+
Concurrent jobs & files
+
`${CONCURRENCY_LEVEL_LABEL[CONCURRENCY_VALUE_TO_LEVEL[v]]} · ${v} job${v === 1 ? '' : 's'} · ${v} file${v === 1 ? '' : 's'} at a time`}
+ />
+
+ How many pull requests are reviewed at once, and how many files within each PR are reviewed at once.
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ { if (!open) setPendingConfirm(null); }}
+ title="This could exceed your rate limit"
+ description={
+ pendingConfirm?.field === 'concurrency'
+ ? 'Running the maximum number of concurrent jobs and files can exceed your model provider\'s rate limits. Continue anyway?'
+ : 'Posting the maximum number of comments per review can increase the chance of hitting your model provider\'s rate limits. Continue anyway?'
+ }
+ confirmLabel="Continue"
+ cancelLabel="Cancel"
+ onConfirm={applyPendingConfirm}
+ />
+
{/* ── LLM Providers ──────────────────────────────────────────────────── */}
@@ -830,9 +802,8 @@ export function SettingsPage() {
const savedProvider = savedProviders.find(saved => saved.id === provider.id);
const dirty = providerDraftDirty(provider, savedProvider);
const modelCount = providerModelCounts.get(provider.id) ?? 0;
- const expanded = expandedProviderId === provider.id;
+ const configOpen = expandedProviderId === provider.id;
const canEnableProvider = providerHasCredential(provider);
- const ready = providerIsReady(provider);
const providerNameId = domId('provider-name', provider.id);
const providerBaseUrlId = domId('provider-base-url', provider.id);
const providerApiKeyId = domId('provider-api-key', provider.id);
@@ -898,16 +869,16 @@ export function SettingsPage() {
setExpandedProviderId(expanded ? null : provider.id)}
- aria-label={expanded ? 'Collapse' : 'Configure'}
+ onClick={() => setExpandedProviderId(configOpen ? null : provider.id)}
+ aria-label={configOpen ? 'Collapse' : 'Configure'}
className={cn(
'ml-0.5 inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
- expanded
+ configOpen
? 'text-foreground'
: 'text-muted-foreground/60 hover:text-muted-foreground',
)}
>
- {expanded ? : }
+ {configOpen ? : }
{customProvider && (
@@ -925,7 +896,7 @@ export function SettingsPage() {
{/* Expanded edit panel */}
- {expanded && (
+ {configOpen && (
{customProvider && (
@@ -970,6 +941,16 @@ export function SettingsPage() {
onChange={e => updateProviderDraft(provider.id, { apiKey: e.target.value })}
className="max-w-sm"
/>
+ {provider.hasApiKey && (
+ void clearProviderKey(provider)}
+ disabled={saving === `provider:${provider.id}`}
+ className="mt-2 text-xs font-medium text-destructive underline-offset-2 hover:underline disabled:opacity-50"
+ >
+ Remove saved key
+
+ )}
)}
@@ -981,6 +962,30 @@ export function SettingsPage() {
)}
+ {/* Global model strategy */}
+
+
+
Default models
+
Used by repos that don't set their own model
+
+
+ {!loading && globalConfig ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+
{/* Footer */}
{!loading && (
@@ -995,343 +1000,57 @@ export function SettingsPage() {
)}
- {/* ── Global model strategy ───────────────────────────────────────────── */}
+ {/* ── About ──────────────────────────────────────────────────────────── */}
- {saving === 'global' ? : }
- Save strategy
-
- }
- >
-
- {!loading && globalConfig ? (
-
- ) : (
-
-
-
-
- )}
-
-
-
- {/* ── Models & Usage Limits ────────────────────────────────────────────── */}
-
- {dirtyConfigs.length > 0 && (
-
- {saving === 'models' ? : }
- Save all ({dirtyConfigs.length})
-
- )}
- setAddingModel(v => !v)}
- className="h-8 gap-1.5 text-xs"
- >
- {addingModel ? : }
- {addingModel ? 'Cancel' : 'Add model'}
-
-
- }
- >
- {/* Add model form */}
- {addingModel && (
-
-
New model
-
-
- {(['rpm', 'rpd', 'tpm'] as const).map(field => (
-
- {field.toUpperCase()}
- setNewModel(current => ({ ...current, [field]: parseOptionalLimit(e.target.value) }))}
- />
-
- ))}
-
-
-
- {saving === 'model:new' ? : }
- Create model
-
-
-
- )}
-
- {/* Filters */}
-
-
-
- setModelSearch(e.target.value)}
- />
-
-
-
-
-
- {filteredConfigs.length}/{configs.length}
-
-
-
- {/* Model list */}
- {loading ? (
-
- {[1, 2, 3].map(i => (
-
-
-
- ))}
-
- ) : (
-
- {filteredConfigs.map((cfg) => {
- const saved = savedConfigs.find(item => item.modelId === cfg.modelId);
- const dirty = !configEqual(cfg, saved);
- const expanded = expandedModelId === cfg.modelId;
- const providerModelNameId = domId('model-provider-name', cfg.modelId);
-
- return (
-
- {/* Row */}
-
- {/* Icon */}
-
-
-
-
- {/* Model info */}
-
-
{cfg.modelId}
-
-
- {cfg.providerName}
-
- {cfg.modelName}
-
-
-
- {/* Rate limits — compact pills */}
-
- {(['rpm', 'rpd', 'tpm'] as const).map(field => (
-
- {field.toUpperCase()} {formatOptionalLimit(cfg[field])}
-
- ))}
-
-
- {/* Actions */}
-
-
testModel(cfg.modelId)}
- className="h-7 gap-1 px-2 text-xs text-muted-foreground"
- aria-label="Test connection"
- >
- {saving === `test:${cfg.modelId}` ? : }
- Test
-
-
setExpandedModelId(expanded ? null : cfg.modelId)}
- className="h-7 w-7 rounded p-0 text-muted-foreground"
- aria-label={expanded ? 'Collapse' : 'Edit'}
- >
- {expanded ? : }
-
-
handleUpdate(cfg.modelId)}
- className="h-7 gap-1 px-2.5 text-xs"
- >
- {saving === cfg.modelId ? : }
- Apply
-
-
deleteModel(cfg.modelId)}
- className="h-7 w-7 text-muted-foreground/40 hover:bg-danger/5 hover:text-danger"
- >
-
-
-
-
-
- {/* Expanded edit panel */}
- {expanded && (
-
- )}
-
- );
- })}
-
- {filteredConfigs.length === 0 && (
-
- No models match the current filters.
-
- )}
-
- )}
-
-
- {/* ── System Information ────────────────────────────────────────────── */}
-
{/* Version row */}
-
-
-
-
-
+
+
+
Version
-
Installed Codra release
-
+
v{pkg.version}
-
+
- {/* Changelog / links row */}
+ {/* License row */}
-
-
-
-
Releases
-
Browse all versions and release notes on GitHub
+
License
+
{pkg.license}
+
+
+
+
+ {/* Links */}
+
+ {[
+ { href: `${pkg.repository.url.replace(/\.git$/, '')}/releases/`, label: 'Releases', sub: 'Version history & notes' },
+ { href: pkg.homepage, label: 'Homepage', sub: 'codra.run' },
+ { href: pkg.bugs.url, label: 'Report an issue', sub: 'GitHub issue tracker' },
+ ].map(({ href, label, sub }) => (
- View releases
-
+
+
+ {label}
+
+
+ {sub}
+
-
-
+ ))}
diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx
index bd7e555..921b863 100644
--- a/src/client/pages/stats.tsx
+++ b/src/client/pages/stats.tsx
@@ -5,24 +5,11 @@ import {
Bar,
BarChart,
CartesianGrid,
- Line,
- LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
-import {
- Activity,
- BarChart3,
- Bot,
- Gauge,
- GitBranch,
- RefreshCw,
- Server,
- TrendingUp,
-} from 'lucide-react';
-
import { TimeRangeSelect } from '@client/components/features/stats/time-range-select';
import { PageHeaderActions } from '@client/components/shared/page-header-actions';
import { PageHeader } from '@client/components/layout/page-header';
@@ -42,7 +29,6 @@ const CHART = {
warning: '#f59e0b',
danger: '#ef4444',
quiet: '#64748b',
- violet: '#8b5cf6',
};
const axisProps = {
@@ -63,11 +49,6 @@ function formatCompact(value: number) {
return value >= 1000 ? fmtNumber(value) : value.toLocaleString();
}
-function ratio(numerator: number, denominator: number, decimals = 1) {
- if (!denominator) return '0';
- return (numerator / denominator).toFixed(decimals);
-}
-
function percent(numerator: number, denominator: number) {
if (!denominator) return 0;
return Math.round((numerator / denominator) * 100);
@@ -111,224 +92,46 @@ function GraphSectionTitle() {
function GraphShell({
title,
- eyebrow,
- icon: Icon,
children,
className = '',
}: {
title: string;
- eyebrow: string;
- icon: typeof Activity;
children: ReactNode;
className?: string;
}) {
return (
-
-
-
-
-
-
+
+
+
{title}
- {children}
+ {children}
);
}
-function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days: number; isDark: boolean }) {
+function MetricsGrid({ stats, days, isDark }: { stats: StatsPayload; days: number; isDark: boolean }) {
const color = isDark ? CHART.primaryDark : CHART.primary;
- const tokenTrend = stats.trend.map((day) => ({
- ...day,
- tokens: day.inputTokens + day.outputTokens,
- tokenDensity: Math.round((day.inputTokens + day.outputTokens) / Math.max(day.jobs, 1)),
- commentRate: Number(ratio(day.comments, Math.max(day.jobs, 1), 1)),
- }));
- const cumulativeTrend = stats.trend.reduce<{ day: string; reviews: number; comments: number }[]>((acc, day) => {
- const prev = acc[acc.length - 1];
- acc.push({
- day: day.day,
- reviews: (prev?.reviews ?? 0) + day.jobs,
- comments: (prev?.comments ?? 0) + day.comments,
- });
- return acc;
- }, []);
const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1);
const modelMax = Math.max(...stats.models.map((model) => model.calls), 1);
- return (
-
-
-
-
-
-
-
-
-
-
-
- } />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } />
-
-
-
-
-
-
-
-
-
-
- {stats.topRepos.slice(0, 7).map((repo) => (
-
-
-
- {repo.owner}/{repo.repo}
-
-
-
-
{repo.jobs}
-
- ))}
-
-
-
-
-
- {stats.models.slice(0, 7).map((model) => (
-
-
-
- {modelName(model.modelUsed)}
-
-
-
-
{model.calls}
-
- ))}
-
-
-
-
-
- );
-}
+ const STATUS_COLOR: Record
= {
+ done: color,
+ running: CHART.comments,
+ queued: CHART.quiet,
+ failed: CHART.danger,
+ superseded: CHART.quiet,
+ };
+ const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1);
-function PanelHeader({
- label,
- value,
- detail,
-}: {
- label: string;
- value?: string;
- detail?: string;
-}) {
return (
-
-
-
-
-
-
-
{label}
- {detail &&
{detail}
}
-
-
- {value && (
-
- {value}
-
- )}
-
- );
-}
-
-function ReviewFlowCard({
- stats,
- days,
- isDark,
-}: {
- stats: StatsPayload;
- days: number;
- isDark: boolean;
-}) {
- const color = isDark ? CHART.primaryDark : CHART.primary;
- const totalReviews = stats.trend.reduce((sum, day) => sum + day.jobs, 0);
- const maxDay = stats.trend.reduce(
- (best, day) => (day.jobs > best.jobs ? day : best),
- stats.trend[0] ?? { day: '', jobs: 0 },
- );
+
+
- return (
-
-
-
-
-
+
+
+
-
+
@@ -340,12 +143,7 @@ function ReviewFlowCard({
-
+
} cursor={{ stroke: color, strokeDasharray: '4 4' }} />
-
-
-
-
- Peak day
-
-
- {maxDay.jobs}
-
- {maxDay.day ? formatDay(maxDay.day) : 'No activity'}
+
-
+
+
+
+
+
+
+
+ } cursor={{ fill: 'var(--muted)' }} />
+
+
+
+
+
+
+
-
-
- Daily average
-
- {ratio(totalReviews, days)}
-
-
-
-
Comment rate
-
- {ratio(stats.totals.comments, Math.max(totalReviews, 1))}
-
+
+
+
+
+ {stats.statuses.map((s) => (
+
+ ))}
-
-
Active repos
-
- {stats.topRepos.length}
-
+
+ {stats.statuses.map((s) => (
+
+
+
+ {s.status}
+
+
{s.count}
+
+ ))}
-
-
-
- );
-}
+
-function ReviewFlowSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {stats.topRepos.slice(0, 8).map((repo) => (
+
+
+
+ {repo.owner}/{repo.repo}
+
+
+
+
{repo.jobs}
+
+ ))}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {stats.models.slice(0, 8).map((model) => (
+
+
+
+ {modelName(model.modelUsed)}
+
+
+
+
{model.calls}
+
+ ))}
-
+
);
@@ -444,14 +255,10 @@ function ReviewFlowSkeleton() {
function GraphCardSkeleton({ className = '' }: { className?: string }) {
return (
-
-
-
-
-
-
+
+
-
+
@@ -461,15 +268,11 @@ function GraphCardSkeleton({ className = '' }: { className?: string }) {
function GraphBarCardSkeleton({ className = '' }: { className?: string }) {
return (
-
-
-
-
-
-
+
+
-
- {Array.from({ length: 5 }).map((_, i) => (
+
+ {Array.from({ length: 8 }).map((_, i) => (
@@ -483,21 +286,18 @@ function GraphBarCardSkeleton({ className = '' }: { className?: string }) {
);
}
-function GraphCardGallerySkeleton() {
+function MetricsGridSkeleton() {
return (
-
+
-
-
-
-
-
-
-
+
+
+
-
);
@@ -546,15 +346,9 @@ export function StatsPage() {
{error &&
{error} }
{stats ? (
- <>
-
-
- >
+
) : (
- <>
-
-
- >
+
)}
);
diff --git a/src/server/app.ts b/src/server/app.ts
index 3a28be2..4265f70 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -11,6 +11,7 @@ import { createJobsRouter } from '@server/routes/api/jobs';
import { createReposRouter } from '@server/routes/api/repos';
import { createStatsRouter } from '@server/routes/api/stats';
import { createModelsRouter } from '@server/routes/api/models';
+import { createSettingsRouter } from '@server/routes/api/settings';
async function serveIndex(c: Context
) {
return c.env.ASSETS.fetch(new URL('/index.html', c.req.url));
@@ -34,6 +35,7 @@ export function createApp() {
app.route('/api/repos', createReposRouter());
app.route('/api/stats', createStatsRouter());
app.route('/api/models', createModelsRouter());
+ app.route('/api/settings', createSettingsRouter());
app.get('/login', serveIndex);
app.get('/', serveIndex); // Unauthenticated landing page
diff --git a/src/server/core/diff.ts b/src/server/core/diff.ts
index 64a5641..3145e37 100644
--- a/src/server/core/diff.ts
+++ b/src/server/core/diff.ts
@@ -32,15 +32,22 @@ const defaultSkipMatchers = ['**/*.lock', '**/package-lock.json', '**/pnpm-lock.
picomatch(pattern, { dot: true }),
);
-export function parseUnifiedDiff(rawDiff: string): FileDiff[] {
- const lines = rawDiff.replace(/\r\n/g, '\n').split('\n');
+export function isReviewableFile(path: string, customMatchers: ReturnType[]) {
+ if (defaultSkipMatchers.some((matcher) => matcher(path))) return false;
+ if (customMatchers.some((matcher) => matcher(path))) return false;
+ return true;
+}
+
+export function parseUnifiedDiff(rawDiff: string, reviewConfig?: RepoConfig['review']): FileDiff[] {
const files: FileDiff[] = [];
+ const customMatchers = reviewConfig?.skip_files?.map((pattern) => picomatch(pattern, { dot: true })) ?? [];
let currentFile: FileDiff | null = null;
let currentHunk: DiffHunk | null = null;
let oldLine = 0;
let newLine = 0;
let position = 0;
+ let isIgnored = false;
const pushCurrentFile = () => {
if (currentFile) {
@@ -51,13 +58,29 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] {
oldLine = 0;
newLine = 0;
position = 0;
+ isIgnored = false;
};
- for (const line of lines) {
+ let startIndex = 0;
+ const length = rawDiff.length;
+
+ while (startIndex < length) {
+ let endIndex = rawDiff.indexOf('\n', startIndex);
+ if (endIndex === -1) {
+ endIndex = length;
+ }
+
+ let line = rawDiff.substring(startIndex, endIndex);
+ if (line.charCodeAt(line.length - 1) === 13) {
+ line = line.slice(0, -1);
+ }
+
+ startIndex = endIndex + 1;
+
if (line.startsWith('diff --git ')) {
pushCurrentFile();
- const parts = line.split(' ');
- const bPath = parts[parts.length - 1];
+ const lastSpace = line.lastIndexOf(' ');
+ const bPath = line.substring(lastSpace + 1);
const path = bPath.startsWith('b/') ? bPath.slice(2) : bPath;
currentFile = {
@@ -69,6 +92,10 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] {
lineCount: 0,
hunks: [],
};
+
+ if (reviewConfig) {
+ isIgnored = !isReviewableFile(path, customMatchers);
+ }
continue;
}
@@ -77,12 +104,16 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] {
}
if (line.startsWith('rename from ')) {
- currentFile.previousPath = line.slice('rename from '.length);
+ currentFile.previousPath = line.slice(12);
continue;
}
if (line.startsWith('rename to ')) {
- currentFile.path = line.slice('rename to '.length);
+ const nextPath = line.slice(10);
+ currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath;
+ if (reviewConfig) {
+ isIgnored = !isReviewableFile(currentFile.path, customMatchers);
+ }
continue;
}
@@ -93,21 +124,30 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] {
if (line.startsWith('deleted file mode ')) {
currentFile.isDeleted = true;
+ isIgnored = true;
continue;
}
if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) {
currentFile.isBinary = true;
- continue;
- }
-
- if (line.startsWith('--- ')) {
+ isIgnored = true;
continue;
}
if (line.startsWith('+++ ')) {
const nextPath = line.slice(4);
currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath;
+ if (reviewConfig) {
+ isIgnored = !isReviewableFile(currentFile.path, customMatchers);
+ }
+ continue;
+ }
+
+ if (isIgnored) {
+ continue;
+ }
+
+ if (line.startsWith('--- ')) {
continue;
}
@@ -132,7 +172,7 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] {
}
const prefix = line[0];
- if (![' ', '+', '-'].includes(prefix)) {
+ if (prefix !== ' ' && prefix !== '+' && prefix !== '-') {
continue;
}
diff --git a/src/server/core/model-output.ts b/src/server/core/model-output.ts
index d2298d5..3ad16f8 100644
--- a/src/server/core/model-output.ts
+++ b/src/server/core/model-output.ts
@@ -398,7 +398,7 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): {
while (current !== prev) {
prev = current;
current = current
- .replace(/^([\u{1F300}-\u{1F9FF}]|\[QUALITY\]|\[SECURITY\]|\[BUG\]|\[PERFORMANCE\]|\[CORRECTNESS\]|\[P[0-3]\]|\[NIT\]|QUALITY|SECURITY|BUG|P[0-3]|NIT|[:\-\s\uFE0F]|[^\w\s])+/giu, '')
+ .replace(/^(?:[^\w\s]+|(?:QUALITY|SECURITY|BUG|PERFORMANCE|CORRECTNESS|P[0-3]|NIT)\b)+/giu, '')
.replace(/\n\s*/g, ' ') // Flatten newlines in titles/snippets
.trim();
}
diff --git a/src/server/core/review.ts b/src/server/core/review.ts
index 4a4d1b3..5d20a7f 100644
--- a/src/server/core/review.ts
+++ b/src/server/core/review.ts
@@ -17,6 +17,7 @@ import {
mapJob,
markJobCheckRunCompleted,
markJobContinuationQueued,
+ resetJobContinuationCount,
releaseJobLease,
supersedeOlderJobs,
updateJobCheckRun,
@@ -32,21 +33,66 @@ import { TokenTracker } from './token-tracker';
import { loadRepoConfig } from './config';
import { getWebhookDelivery } from '@server/db/webhook-deliveries';
import { sendTelemetryEvent } from './telemetry';
+import { getReviewSettings } from '@server/db/app-settings';
+import { REVIEW_CONCURRENCY_LIMITS } from '@shared/schema';
type PersistedReviewJob = ReturnType;
-export type ReviewJobRunResult =
- | { action: 'ack' }
+export type ReviewJobRunResult =
+ | { action: 'ack' }
| { action: 'retry'; delaySeconds: number }
| { action: 'next_phase'; phase: 'prepare' | 'review' | 'finalize'; delaySeconds: number };
-const REVIEW_CHUNK_FILE_LIMIT = 3;
const REVIEW_CHUNK_WALL_CLOCK_MS = 12 * 60 * 1000;
-const MAX_CONCURRENT_JOBS = 4;
const JOB_LEASE_SECONDS = 15 * 60;
const BUSY_RETRY_SECONDS = 60;
-const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [60, 5 * 60, 15 * 60];
+// Backoff between deferred retries of a file that hit a transient model/provider failure.
+// Kept short at first: most observed failures are momentary provider load (Gemini 500/503
+// "high demand") or self-inflicted connection queuing, both of which clear within seconds,
+// so a long first delay just makes reviews grind. Later attempts back off harder in case the
+// provider really is having an outage.
+const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [30, 2 * 60, 5 * 60];
+// Subrequest-budget exhaustion is not a provider outage: the budget resets the moment a fresh
+// invocation starts, so the continuation only needs a token pause (the workflow already forces
+// a >=1s yield between phases), not a provider-style backoff.
+const SUBREQUEST_BUDGET_RETRY_DELAY_SECONDS = 5;
const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 3;
+// Belt-and-suspenders ceiling on how many times a job may reschedule the *same* phase without
+// completing a single file (see markJobContinuationQueued / resetJobContinuationCount). The
+// per-file cap above is the primary bound and resets this counter on any progress, so a healthy
+// job never approaches this; it only fires when a job is genuinely wedged (e.g. a provider is
+// down for the whole backoff window) and stops it from churning for hours before finalizing.
+const MAX_JOB_CONTINUATIONS = 20;
+// A job's commit (and therefore its diff) never changes, so the raw diff can be
+// cached for the job's entire lifetime instead of being re-fetched from GitHub on
+// every prepare/review-chunk/finalize phase. 6h comfortably covers even a job that
+// hits every retryable-failure backoff (up to 15 min each, several times over).
+const DIFF_CACHE_TTL_SECONDS = 6 * 60 * 60;
+// Estimated subrequest cost of reviewing one file, used only to size how many files can
+// safely be reviewed concurrently in a chunk given the job's remaining subrequest budget for
+// this invocation (see budgetAwareChunkFileLimit below). A file walks a fallback chain of up
+// to ~3 models, but the per-model model-config lookup is now cached per invocation
+// (ModelService.resolveModel), so the recurring cost per file is ~1 provider call per model
+// tried plus the persisted-review write -- roughly 5 in the worst case rather than 9. Lower
+// estimate => more files reviewed in parallel per chunk within the same 50-subrequest cap.
+const ESTIMATED_SUBREQUESTS_PER_FILE = 8;
+
+/**
+ * How many files a single review chunk may process concurrently: the configured concurrency
+ * level, capped only by what the invocation's remaining subrequest budget can safely cover.
+ *
+ * The cap is deliberately sized so it does NOT silently override the user's chosen concurrency
+ * at a healthy budget -- that would make the concurrency setting a no-op above the cap. It
+ * only throttles once earlier failures in this invocation have actually eaten into the budget;
+ * if there is not enough safe budget for one more file, the chunk yields and resumes in a fresh
+ * invocation instead of gambling past the margin. Any files a throttled chunk can't reach roll
+ * into the next chunk. The
+ * chunk-file-limit-honors-configured-level invariant is pinned by a regression test.
+ */
+export function budgetAwareFileLimit(remainingSafeBudget: number, configuredChunkFileLimit: number) {
+ const budgetLimit = Math.floor(remainingSafeBudget / ESTIMATED_SUBREQUESTS_PER_FILE);
+ return Math.min(configuredChunkFileLimit, budgetLimit);
+}
function isRetryableFileReviewErrorMessage(message: string | null | undefined) {
if (!message) return false;
@@ -64,10 +110,27 @@ function isRetryableFileReviewErrorMessage(message: string | null | undefined) {
lower.includes('temporary') ||
lower.includes('[redacted]') ||
lower.includes('returned no review content') ||
- lower.includes('empty response')
+ lower.includes('empty response') ||
+ // Older jobs may have persisted subrequest-budget failures before budget exhaustion became
+ // a pure chunk-level deferral. Keep retrying those rows instead of treating them as handled.
+ lower.includes('subrequest')
);
}
+/**
+ * Detects Cloudflare's per-invocation subrequest-limit error (Workers Free plan: 50
+ * subrequests/invocation). Unlike a provider outage, this clears completely on the next
+ * invocation, so the correct response is never to fail the whole job or permanently abandon
+ * a file -- it is to persist whatever progress was made and reschedule the same phase, which
+ * runs in a fresh invocation with a fresh budget. Because each review chunk reviews and
+ * persists only a few files (see reviewChunkFileLimit), rescheduling reliably makes forward
+ * progress and the review grinds to completion instead of dying mid-way.
+ */
+function isSubrequestBudgetError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error ?? '');
+ return message.toLowerCase().includes('subrequest');
+}
+
function retryableModelFailureDelaySeconds(failureCount: number | null | undefined) {
if (!failureCount || failureCount < 1) return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0];
const index = Math.min(failureCount - 1, RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS.length - 1);
@@ -213,8 +276,10 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage):
return { action: 'ack' };
}
+ const { concurrencyLevel } = await getReviewSettings(env);
+ const maxConcurrentJobs = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel];
const runningCount = await getOtherRunningJobsCount(env, resolved.job.id);
- if (runningCount >= MAX_CONCURRENT_JOBS) {
+ if (runningCount >= maxConcurrentJobs) {
logger.info(`Throttling job ${resolved.job.id}: ${runningCount} other jobs are currently running.`);
return { action: 'retry', delaySeconds: 30 };
}
@@ -247,7 +312,7 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage):
} else if (phase === 'finalize') {
await runFinalizePhase(env, job, leaseOwner, github, formatter);
} else {
- await runReviewPhase(env, job, leaseOwner, github, model);
+ await runReviewPhase(env, job, leaseOwner, github, model, tracker);
}
await releaseJobLease(env, job.id, leaseOwner);
@@ -272,9 +337,27 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage):
phase,
delaySeconds,
});
- await markJobContinuationQueued(env, job.id, delaySeconds);
- await releaseJobLease(env, job.id, leaseOwner);
- return { action: 'next_phase', phase, delaySeconds }; // Retry same phase
+ return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'transient model/provider failures');
+ }
+
+ // Running out of this invocation's subrequest budget is not a job failure: the prepare and
+ // review phases are idempotent (already-reviewed files are persisted and skipped next time),
+ // so reschedule the same phase to resume on a fresh budget instead of terminally failing the
+ // whole review. Finalize is intentionally excluded -- it posts the GitHub review and isn't
+ // safe to re-run, and with no AI calls it does not realistically approach the cap anyway.
+ if (phase !== 'finalize' && isSubrequestBudgetError(error)) {
+ // Honor an explicit retryAfterSeconds if one was attached; otherwise resume almost
+ // immediately -- the fresh invocation is what fixes budget exhaustion, not waiting.
+ const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null;
+ const delaySeconds = typeof record?.retryAfterSeconds === 'number'
+ ? record.retryAfterSeconds
+ : SUBREQUEST_BUDGET_RETRY_DELAY_SECONDS;
+ logger.warn(`Review job hit the per-invocation subrequest limit; rescheduling ${phase} on a fresh budget: ${job.owner}/${job.repo} PR #${job.prNumber}`, {
+ error: messageText,
+ phase,
+ delaySeconds,
+ });
+ return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'per-invocation subrequest limits');
}
logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error);
@@ -284,6 +367,38 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage):
}
}
+// Records a same-phase continuation and enforces the MAX_JOB_CONTINUATIONS ceiling. As long as
+// the job keeps completing files, resetJobContinuationCount() keeps this counter near zero; once
+// it has rescheduled MAX_JOB_CONTINUATIONS times without a single file completing, the job is
+// genuinely wedged (e.g. a provider is down for the entire backoff window), so we fail it
+// terminally instead of letting it churn indefinitely.
+async function continueOrFailWedgedJob(
+ env: AppBindings,
+ job: PersistedReviewJob,
+ github: GitHubService,
+ leaseOwner: string,
+ phase: 'prepare' | 'review' | 'finalize',
+ delaySeconds: number,
+ reason: string,
+): Promise {
+ const continuationCount = await markJobContinuationQueued(env, job.id, delaySeconds);
+
+ if (continuationCount > MAX_JOB_CONTINUATIONS) {
+ const message = `Review could not make progress after ${continuationCount} continuation attempts (${reason}). Failing the job to avoid an endless retry loop; re-run it once the underlying provider issue clears.`;
+ logger.error(`Review job exceeded the continuation ceiling; failing terminally: ${job.owner}/${job.repo} PR #${job.prNumber}`, {
+ phase,
+ continuationCount,
+ reason,
+ });
+ await failJobAndCheckRun(env, job, github, message);
+ await releaseJobLease(env, job.id, leaseOwner);
+ return { action: 'ack' };
+ }
+
+ await releaseJobLease(env, job.id, leaseOwner);
+ return { action: 'next_phase', phase, delaySeconds }; // Resume same phase
+}
+
async function resolveQueuedJob(
env: AppBindings,
message: ReviewJobMessage,
@@ -437,8 +552,7 @@ async function runPreparePhase(
await updateJobCheckRun(env, job.id, checkRun.id);
}
- const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber);
- const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review);
+ const files = await getDiffFiles(env, job, github, config);
await completePreparationStep(env, job.id, files.length);
await heartbeatJobLease(env, job.id, leaseOwner, JOB_LEASE_SECONDS);
@@ -449,10 +563,16 @@ async function runPreparePhase(
}
if (checkRunId) {
- await github.updateCheckRun(job.owner, job.repo, checkRunId, {
- title: `Reviewing (0/${files.length})`,
- summary: 'Codra is analyzing changed files.',
- });
+ // Best-effort progress cosmetics only (see runReviewPhase): don't let a failed check-run
+ // update block enqueuing the review phase that does the actual work.
+ try {
+ await github.updateCheckRun(job.owner, job.repo, checkRunId, {
+ title: `Reviewing (0/${files.length})`,
+ summary: 'Codra is analyzing changed files.',
+ });
+ } catch (error) {
+ logger.warn(`Failed to update initial progress check run for job ${job.id}; continuing to the review phase anyway`, error instanceof Error ? error : new Error(String(error)));
+ }
}
await enqueueJobPhase(env, job.id, 'review');
}
@@ -463,6 +583,7 @@ async function runReviewPhase(
leaseOwner: string,
github: GitHubService,
model: ModelService,
+ tracker: TokenTracker,
) {
if (!hasCompletedStep(job, 'Preparation')) {
await runPreparePhase(env, job, leaseOwner, github);
@@ -479,9 +600,18 @@ async function runReviewPhase(
failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId);
return failureModelProviderPromise;
};
- const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber);
- const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review);
+ const files = await getDiffFiles(env, job, github, config);
const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0);
+ const { concurrencyLevel } = await getReviewSettings(env);
+ const configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel];
+ // Cap this chunk's concurrency by the invocation's remaining subrequest budget so a run of
+ // model/provider failures can't push it over Cloudflare's per-invocation cap (Workers Free
+ // plan: 50) -- but sized (see budgetAwareFileLimit) so the configured concurrency level is
+ // honored in full at a healthy budget and only throttled once the budget is actually spent.
+ const reviewChunkFileLimit = budgetAwareFileLimit(tracker.remainingSafeBudget(), configuredChunkFileLimit);
+ if (reviewChunkFileLimit <= 0) {
+ throw new Error('Subrequest budget for this invocation was exhausted before starting the next review chunk.');
+ }
const startedAt = Date.now();
let processedThisChunk = 0;
@@ -535,7 +665,7 @@ async function runReviewPhase(
reviewTasks.push(reviewTask());
processedThisChunk += 1;
- if (processedThisChunk >= REVIEW_CHUNK_FILE_LIMIT || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) {
+ if (processedThisChunk >= reviewChunkFileLimit || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) {
break;
}
}
@@ -543,17 +673,26 @@ async function runReviewPhase(
const results = await Promise.allSettled(reviewTasks);
await heartbeatAndCheckSuperseded(env, job.id, leaseOwner);
+ // A fulfilled task means a file reached a terminal state this chunk (reviewed, inherited, or
+ // marked permanently failed) -- i.e. the job made progress. Clear the no-progress continuation
+ // counter so a slow-but-advancing job never trips the MAX_JOB_CONTINUATIONS safety net; only a
+ // chunk that completes *nothing* is allowed to push the job toward that ceiling.
+ if (results.some((result) => result.status === 'fulfilled')) {
+ await resetJobContinuationCount(env, job.id);
+ }
+
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
if (rejected.length > 0) {
rejected.forEach((result, index) => {
logger.error(`Review chunk task ${index + 1}/${rejected.length} failed`, result.reason);
});
- // If any of the rejected tasks were transient model errors, we must throw a RetryableModelError
- // so that the job orchestrator retries the chunk, instead of failing the job with an AggregateError.
- const retryableError = rejected.map(r => r.reason).find(isRetryableModelError);
- if (retryableError) {
- throw retryableError;
+ // If any rejected task was a transient model error or a per-invocation subrequest-budget
+ // hit, surface that single error so the job orchestrator reschedules the chunk on a fresh
+ // budget, instead of failing the job with an AggregateError.
+ const deferrableError = rejected.map(r => r.reason).find(r => isRetryableModelError(r) || isSubrequestBudgetError(r));
+ if (deferrableError) {
+ throw deferrableError;
}
throw rejected.length === 1
@@ -572,10 +711,17 @@ async function runReviewPhase(
}
if (job.checkRunId) {
- await github.updateCheckRun(job.owner, job.repo, job.checkRunId, {
- title: `Reviewing (${completedCount}/${files.length})`,
- summary: 'Codra is continuing this review in the next queue chunk.',
- });
+ // Best-effort progress cosmetics only: the file reviews for this chunk are already
+ // persisted, so a failure here (e.g. this invocation's subrequest budget is spent) must
+ // not stop us from enqueuing the next chunk that finishes the job.
+ try {
+ await github.updateCheckRun(job.owner, job.repo, job.checkRunId, {
+ title: `Reviewing (${completedCount}/${files.length})`,
+ summary: 'Codra is continuing this review in the next queue chunk.',
+ });
+ } catch (error) {
+ logger.warn(`Failed to update progress check run for job ${job.id}; continuing to the next chunk anyway`, error instanceof Error ? error : new Error(String(error)));
+ }
}
await enqueueJobPhase(env, job.id, 'review');
}
@@ -626,6 +772,22 @@ async function reviewAndPersistFile(
const modelId = config.model?.main ?? 'unconfigured';
const modelProvider = await resolveFailureModelProvider();
+ // Per-invocation subrequest pressure clears on the next Worker invocation, so do not count
+ // it as a per-file provider outage. Let the job-level no-progress continuation ceiling bound
+ // a genuinely wedged job while this file remains pending for the fresh-budget retry.
+ if (isSubrequestBudgetError(error)) {
+ logger.warn(`File review deferred for ${file.path}; subrequest budget will retry in a fresh invocation`, {
+ error: errorMessage,
+ });
+ Object.defineProperty(error, 'retryAfterSeconds', {
+ value: SUBREQUEST_BUDGET_RETRY_DELAY_SECONDS,
+ configurable: true,
+ });
+ throw error;
+ }
+
+ // Transient model/provider outages count against the file so a single unrecoverable file
+ // eventually becomes a partial-review failure instead of blocking the entire job forever.
if (isRetryableModelError(error)) {
const failureCount = await recordRetryableFileReviewFailure(env, job.id, {
filePath: file.path,
@@ -675,13 +837,16 @@ async function reviewAndPersistFile(
logger.error(`File review failed for ${file.path}`, { error });
+ // A genuine provider allocation exhaustion (e.g. Cloudflare Workers AI daily free
+ // allocation, error 4006) won't clear by retrying within this job, so mark the file failed
+ // and let the PR review complete as a partial review. (Per-invocation subrequest limits are
+ // NOT hard limits -- they're handled as deferrals above and retried on a fresh budget.)
const isHardLimit =
- errorMessage.toLowerCase().includes('subrequest') ||
errorMessage.includes('4006') ||
errorMessage.toLowerCase().includes('allocation');
if (isHardLimit) {
- logger.warn(`File review hit hard limit for ${file.path}, marking as failed to allow partial PR review.`, { error: errorMessage });
+ logger.warn(`File review hit hard provider allocation limit for ${file.path}, marking as failed to allow partial PR review.`, { error: errorMessage });
// We don't throw here; we just fall through and let it be marked as failed
// so the PR review can continue and complete as a partial review.
}
@@ -716,8 +881,7 @@ async function runFinalizePhase(
const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber);
const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig;
- const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber);
- const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review);
+ const files = await getDiffFiles(env, job, github, config);
const reviews = await getFileReviewsForJobs(env, [job.id]);
if (reviews.length < files.length) {
@@ -773,13 +937,15 @@ async function runFinalizePhase(
const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length;
const severityRanks: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 };
const minRank = severityRanks[config.review.min_severity] ?? 4;
-
+ const { maxComments: globalMaxComments } = await getReviewSettings(env);
+ const effectiveMaxComments = Math.min(config.review.max_comments, globalMaxComments);
+
let finalComments = reviewedComments.filter(c => (severityRanks[c.severity] ?? 4) <= minRank);
finalComments.sort((a, b) => (severityRanks[a.severity] ?? 4) - (severityRanks[b.severity] ?? 4));
-
- const omittedCount = reviewedComments.length - Math.min(finalComments.length, config.review.max_comments);
- if (finalComments.length > config.review.max_comments) {
- finalComments = finalComments.slice(0, config.review.max_comments);
+
+ const omittedCount = reviewedComments.length - Math.min(finalComments.length, effectiveMaxComments);
+ if (finalComments.length > effectiveMaxComments) {
+ finalComments = finalComments.slice(0, effectiveMaxComments);
}
const verdictSummary = formatter.summarizeVerdict(finalComments, hasFailures);
@@ -787,9 +953,9 @@ async function runFinalizePhase(
await heartbeatAndCheckSuperseded(env, job.id, leaseOwner);
let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME);
-
+
if (omittedCount > 0) {
- formattedSummary += `\n\n> [!NOTE]\n> **${omittedCount} comments were omitted** from this review to reduce noise and respect the configured \`max_comments\` limit (${config.review.max_comments}). Showing the most critical issues.`;
+ formattedSummary += `\n\n> [!NOTE]\n> **${omittedCount} comments were omitted** from this review to reduce noise and respect the configured \`max_comments\` limit (${effectiveMaxComments}). Showing the most critical issues.`;
}
await updateJobStep(env, job.id, 'Completing', { status: 'running' });
@@ -912,14 +1078,56 @@ function hasCompletedStep(job: PersistedReviewJob, stepName: string) {
return job.steps.some((step) => step.name === stepName && step.status === 'done');
}
-async function failJobAndCheckRun(
+function diffCacheKey(jobId: string) {
+ return `diff:${jobId}`;
+}
+
+/**
+ * Returns the job's reviewable files, fetching and parsing the PR diff from
+ * GitHub only once per job (cached in KV) instead of once per phase invocation.
+ */
+export async function getDiffFiles(
env: AppBindings,
- job: PersistedReviewJob,
- github: GitHubService,
+ job: Pick,
+ github: Pick,
+ config: RepoConfig,
+) {
+ const cacheKey = diffCacheKey(job.id);
+ let rawDiff = await env.APP_KV.get(cacheKey);
+
+ if (!rawDiff) {
+ rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber);
+ try {
+ await env.APP_KV.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS });
+ } catch (error) {
+ logger.warn(`Failed to cache PR diff for job ${job.id}; it will be re-fetched on the next phase`, error instanceof Error ? error : new Error(String(error)));
+ }
+ }
+
+ return filterReviewableFiles(parseUnifiedDiff(rawDiff, config.review), config.review);
+}
+
+export async function failJobAndCheckRun(
+ env: AppBindings,
+ job: Pick,
+ github: Pick,
message: string,
) {
+ // Marking the job failed in the DB is the critical, must-not-lose write: it's what
+ // makes the job terminal so it stops being retried, and it's what makes it eligible
+ // for completeTerminalCheckRuns() to pick up later if the GitHub call below fails.
try {
await failJob(env, job.id, message);
+ } catch (dbError) {
+ logger.error(`Critical: failed to mark job ${job.id} as failed in the DB; it may remain stuck until lease-expiry recovery reclaims it`, dbError);
+ return;
+ }
+
+ // Updating the GitHub check run is best-effort here. If it fails (e.g. the Worker's
+ // subrequest budget for this invocation is already exhausted from the review itself),
+ // the job is still durably marked failed above, and the opportunistic maintenance sweep
+ // (completeTerminalCheckRuns) will retry this update on a later invocation with a fresh budget.
+ try {
const latest = await getJobForProcessing(env, job.id);
const checkRunId = latest?.check_run_id ?? job.checkRunId;
if (checkRunId) {
@@ -931,7 +1139,7 @@ async function failJobAndCheckRun(
});
await markJobCheckRunCompleted(env, job.id);
}
- } catch (innerError) {
- logger.error('Failed to record job failure in DB/GitHub', innerError);
+ } catch (checkRunError) {
+ logger.warn(`Failed to update GitHub check run for failed job ${job.id}; opportunistic maintenance will retry it`, checkRunError);
}
}
diff --git a/src/server/core/token-tracker.ts b/src/server/core/token-tracker.ts
index c7951f1..88d1e0c 100644
--- a/src/server/core/token-tracker.ts
+++ b/src/server/core/token-tracker.ts
@@ -14,7 +14,7 @@ export class TokenTracker {
private usage: Map = new Map();
private subrequests = 0;
private readonly MAX_SUBREQUESTS = 50;
- private readonly SAFE_MARGIN = 22; // Increased even further to handle finalization overhead
+ private readonly SAFE_MARGIN = 10; // Lowered since finalize runs in a separate invocation
incrementSubrequests(count = 1) {
this.subrequests += count;
@@ -32,6 +32,18 @@ export class TokenTracker {
return this.subrequests >= this.MAX_SUBREQUESTS - this.SAFE_MARGIN;
}
+ /**
+ * How many more subrequests can safely be spent right now before crossing into the
+ * reserved safety margin below Cloudflare's hard per-invocation cap (Workers Free plan:
+ * 50 subrequests/invocation). Callers that can start a variable amount of concurrent work
+ * (e.g. how many files to review at once) should size that work against this number
+ * instead of a fixed constant, so throughput stays high while the budget is healthy and
+ * automatically shrinks as it's spent.
+ */
+ remainingSafeBudget() {
+ return Math.max(0, this.MAX_SUBREQUESTS - this.SAFE_MARGIN - this.subrequests);
+ }
+
/**
* Records token usage for a specific model call.
*/
diff --git a/src/server/db/app-settings.ts b/src/server/db/app-settings.ts
new file mode 100644
index 0000000..5c496e6
--- /dev/null
+++ b/src/server/db/app-settings.ts
@@ -0,0 +1,48 @@
+import type { AppBindings } from '@server/env';
+import { queryRows } from './client';
+import { logger } from '@server/core/logger';
+import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewSettingsSchema, type ReviewSettings } from '@shared/schema';
+
+const CONCURRENCY_KEY = 'review_concurrency_level';
+const MAX_COMMENTS_KEY = 'review_max_comments';
+
+const DEFAULT_REVIEW_SETTINGS: ReviewSettings = reviewSettingsSchema.parse({});
+const CONCURRENCY_LEVELS = new Set(reviewConcurrencyLevels);
+const MAX_COMMENTS_OPTIONS = new Set(reviewMaxCommentsOptions);
+
+export async function getReviewSettings(env: Pick): Promise {
+ try {
+ const rows = await queryRows<{ key: string; value: string }>(
+ env,
+ 'SELECT key, value FROM global_settings WHERE key = ANY($1)',
+ [[CONCURRENCY_KEY, MAX_COMMENTS_KEY]],
+ );
+ const map = new Map(rows.map((row) => [row.key, row.value]));
+ const storedConcurrency = map.get(CONCURRENCY_KEY);
+ const storedMaxComments = map.get(MAX_COMMENTS_KEY);
+ const parsedMaxComments = storedMaxComments === undefined ? NaN : Number(storedMaxComments);
+
+ return reviewSettingsSchema.parse({
+ concurrencyLevel: storedConcurrency && CONCURRENCY_LEVELS.has(storedConcurrency)
+ ? storedConcurrency
+ : DEFAULT_REVIEW_SETTINGS.concurrencyLevel,
+ maxComments: MAX_COMMENTS_OPTIONS.has(parsedMaxComments)
+ ? parsedMaxComments
+ : DEFAULT_REVIEW_SETTINGS.maxComments,
+ });
+ } catch (error) {
+ logger.warn('Failed to load review settings, using defaults', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return DEFAULT_REVIEW_SETTINGS;
+ }
+}
+
+export async function updateReviewSettings(env: Pick, settings: ReviewSettings): Promise {
+ await queryRows(
+ env,
+ `INSERT INTO global_settings (key, value) VALUES ($1, $2), ($3, $4)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
+ [CONCURRENCY_KEY, settings.concurrencyLevel, MAX_COMMENTS_KEY, String(settings.maxComments)],
+ );
+}
diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts
index 9f263b4..b0ba5de 100644
--- a/src/server/db/jobs.ts
+++ b/src/server/db/jobs.ts
@@ -26,6 +26,7 @@ export type JobRow = {
lease_expires_at: string | null;
heartbeat_at: string | null;
recovery_count: number | null;
+ continuation_count: number | null;
last_queue_message_at: string | null;
total_input_tokens: number | null;
total_output_tokens: number | null;
@@ -518,21 +519,45 @@ export async function releaseJobLease(env: Pick, jobI
);
}
+// Records that a job is rescheduling the same phase (a continuation) and returns the resulting
+// no-progress continuation count. The counter is bumped here and cleared by
+// resetJobContinuationCount() whenever a chunk actually completes a file, so a healthy job that
+// keeps making headway stays near zero while a job that can never progress climbs toward the
+// MAX_JOB_CONTINUATIONS ceiling and is failed terminally.
export async function markJobContinuationQueued(env: Pick, jobId: string, delaySeconds = 0) {
- await queryRows(
+ const rows = await queryRows<{ continuation_count: number }>(
env,
`
UPDATE jobs
SET heartbeat_at = now(),
+ continuation_count = continuation_count + 1,
last_queue_message_at = CASE
WHEN $2::int > 0 THEN now() + ($2::text || ' seconds')::interval
ELSE now()
END
WHERE id = $1
AND status = 'running'
+ RETURNING continuation_count
`,
[jobId, delaySeconds],
);
+ return rows[0]?.continuation_count ?? 0;
+}
+
+// Clears the no-progress continuation counter after a chunk completes at least one file review,
+// so slow-but-progressing jobs never trip the MAX_JOB_CONTINUATIONS safety net.
+export async function resetJobContinuationCount(env: Pick, jobId: string) {
+ await queryRows(
+ env,
+ `
+ UPDATE jobs
+ SET continuation_count = 0
+ WHERE id = $1
+ AND status = 'running'
+ AND continuation_count <> 0
+ `,
+ [jobId],
+ );
}
export async function updateJobCheckRun(env: Pick, jobId: string, checkRunId: number) {
diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts
index 453f51e..51e90b8 100644
--- a/src/server/db/model-configs.ts
+++ b/src/server/db/model-configs.ts
@@ -26,9 +26,6 @@ type ModelConfigRow = {
provider_name: string;
api_format: LlmApiFormat;
model_name: string;
- rpm: number | null;
- tpm: number | null;
- rpd: number | null;
updated_at: string;
};
@@ -69,9 +66,6 @@ function mapModelConfig(row: ModelConfigRow): ModelConfig {
providerName: row.provider_name,
apiFormat: row.api_format,
modelName: row.model_name,
- rpm: row.rpm,
- tpm: row.tpm,
- rpd: row.rpd,
updatedAt: row.updated_at,
});
}
@@ -83,9 +77,6 @@ const MODEL_SELECT = `
p.name AS provider_name,
p.api_format,
mc.model_name,
- mc.rpm,
- mc.tpm,
- mc.rpd,
mc.updated_at
FROM model_configs mc
JOIN llm_providers p ON p.id = mc.provider_id
@@ -248,9 +239,6 @@ export async function getResolvedModelConfig(
p.name AS provider_name,
p.api_format,
mc.model_name,
- mc.rpm,
- mc.tpm,
- mc.rpd,
mc.updated_at,
p.enabled AS provider_enabled,
p.base_url,
@@ -279,20 +267,17 @@ export async function updateModelConfig(
env,
`
WITH upserted AS (
- INSERT INTO model_configs (model_id, provider_id, model_name, rpm, tpm, rpd, provider, updated_at)
- SELECT $1, p.id, $3, $4, $5, $6, p.api_format, now()
+ INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at)
+ SELECT $1, p.id, $3, p.api_format, now()
FROM llm_providers p
WHERE p.id = $2
ON CONFLICT (model_id)
DO UPDATE SET
provider_id = EXCLUDED.provider_id,
model_name = EXCLUDED.model_name,
- rpm = EXCLUDED.rpm,
- tpm = EXCLUDED.tpm,
- rpd = EXCLUDED.rpd,
provider = EXCLUDED.provider,
updated_at = now()
- RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at
+ RETURNING model_id, provider_id, model_name, updated_at
)
SELECT
u.model_id,
@@ -300,14 +285,11 @@ export async function updateModelConfig(
p.name AS provider_name,
p.api_format,
u.model_name,
- u.rpm,
- u.tpm,
- u.rpd,
u.updated_at
FROM upserted u
JOIN llm_providers p ON p.id = u.provider_id
`,
- [config.modelId, config.providerId, config.modelName, config.rpm, config.tpm, config.rpd],
+ [config.modelId, config.providerId, config.modelName],
);
return row ? mapModelConfig(row) : null;
}
@@ -352,9 +334,6 @@ export async function upsertDiscoveredModelConfigs(
model_id: string;
provider_id: string;
model_name: string;
- rpm: number | null;
- tpm: number | null;
- rpd: number | null;
provider: LlmApiFormat;
}> = [];
@@ -374,9 +353,6 @@ export async function upsertDiscoveredModelConfigs(
model_id: candidate,
provider_id: input.providerId,
model_name: modelName,
- rpm: null,
- tpm: null,
- rpd: null,
provider: input.apiFormat,
});
}
@@ -386,9 +362,6 @@ export async function upsertDiscoveredModelConfigs(
const modelIds = rowsToInsert.map(row => row.model_id);
const providerIds = rowsToInsert.map(row => row.provider_id);
const modelNames = rowsToInsert.map(row => row.model_name);
- const rpms = rowsToInsert.map(row => row.rpm);
- const tpms = rowsToInsert.map(row => row.tpm);
- const rpds = rowsToInsert.map(row => row.rpd);
const providers = rowsToInsert.map(row => row.provider);
const rows = await queryRows(
@@ -400,18 +373,15 @@ export async function upsertDiscoveredModelConfigs(
$1::text[],
$2::uuid[],
$3::text[],
- $4::integer[],
- $5::integer[],
- $6::integer[],
- $7::text[]
- ) AS item(model_id, provider_id, model_name, rpm, tpm, rpd, provider)
+ $4::text[]
+ ) AS item(model_id, provider_id, model_name, provider)
),
inserted AS (
- INSERT INTO model_configs (model_id, provider_id, model_name, rpm, tpm, rpd, provider, updated_at)
- SELECT model_id, provider_id, model_name, rpm, tpm, rpd, provider, now()
+ INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at)
+ SELECT model_id, provider_id, model_name, provider, now()
FROM incoming
ON CONFLICT (model_id) DO NOTHING
- RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at
+ RETURNING model_id, provider_id, model_name, updated_at
)
SELECT
i.model_id,
@@ -419,15 +389,12 @@ export async function upsertDiscoveredModelConfigs(
p.name AS provider_name,
p.api_format,
i.model_name,
- i.rpm,
- i.tpm,
- i.rpd,
i.updated_at
FROM inserted i
JOIN llm_providers p ON p.id = i.provider_id
ORDER BY i.model_id ASC
`,
- [modelIds, providerIds, modelNames, rpms, tpms, rpds, providers],
+ [modelIds, providerIds, modelNames, providers],
);
return rows.map(mapModelConfig);
diff --git a/src/server/db/stats.ts b/src/server/db/stats.ts
index 42d1175..457aa65 100644
--- a/src/server/db/stats.ts
+++ b/src/server/db/stats.ts
@@ -1,13 +1,18 @@
import type { AppBindings } from '@server/env';
import { queryRows } from './client';
-import { statsSchema } from '@shared/schema';
+import { statsSchema, jobStatuses, reviewTriggers, reviewSeverities, reviewCategories } from '@shared/schema';
import { getModelUsageStats } from './file-reviews';
+const jobStatusSet = new Set(jobStatuses);
+const reviewTriggerSet = new Set(reviewTriggers);
+const reviewSeveritySet = new Set(reviewSeverities);
+const reviewCategorySet = new Set(reviewCategories);
+
export async function getStats(env: Pick, days = 30) {
const parsedDays = Number(days);
const safeDays = Number.isFinite(parsedDays) ? Math.trunc(parsedDays) : 30;
const clampedDays = Math.min(Math.max(safeDays, 1), 365);
- const [[totals], dailyRows, verdictRows, topRepos, modelRows] = await Promise.all([
+ const [[totals], dailyRows, verdictRows, topRepos, modelRows, statusRows, triggerRows, severityRows, categoryRows, [performanceRow]] = await Promise.all([
queryRows<{
jobs: number;
input_tokens: number;
@@ -61,6 +66,64 @@ export async function getStats(env: Pick, days = 30)
`,
),
getModelUsageStats(env),
+ queryRows<{ status: string; count: number }>(
+ env,
+ `
+ SELECT status, COUNT(*)::int AS count
+ FROM jobs
+ WHERE created_at >= now() - ($1::int * interval '1 day')
+ GROUP BY status
+ ORDER BY count DESC
+ `,
+ [clampedDays],
+ ),
+ queryRows<{ trigger: string; count: number }>(
+ env,
+ `
+ SELECT trigger, COUNT(*)::int AS count
+ FROM jobs
+ WHERE created_at >= now() - ($1::int * interval '1 day')
+ GROUP BY trigger
+ ORDER BY count DESC
+ `,
+ [clampedDays],
+ ),
+ queryRows<{ severity: string; count: number }>(
+ env,
+ `
+ SELECT rc.severity, COUNT(*)::int AS count
+ FROM review_comments rc
+ JOIN file_reviews fr ON fr.id = rc.file_review_id
+ WHERE fr.created_at >= now() - ($1::int * interval '1 day')
+ GROUP BY rc.severity
+ ORDER BY count DESC
+ `,
+ [clampedDays],
+ ),
+ queryRows<{ category: string; count: number }>(
+ env,
+ `
+ SELECT rc.category, COUNT(*)::int AS count
+ FROM review_comments rc
+ JOIN file_reviews fr ON fr.id = rc.file_review_id
+ WHERE fr.created_at >= now() - ($1::int * interval '1 day')
+ GROUP BY rc.category
+ ORDER BY count DESC
+ `,
+ [clampedDays],
+ ),
+ queryRows<{ avg_duration_ms: number | null; p95_duration_ms: number | null; avg_confidence: number | null }>(
+ env,
+ `
+ SELECT
+ AVG(EXTRACT(EPOCH FROM (finished_at - started_at)) * 1000) AS avg_duration_ms,
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (finished_at - started_at)) * 1000) AS p95_duration_ms,
+ AVG(overall_confidence_score) AS avg_confidence
+ FROM jobs
+ WHERE finished_at IS NOT NULL AND started_at IS NOT NULL AND created_at >= now() - ($1::int * interval '1 day')
+ `,
+ [clampedDays],
+ ),
]);
return statsSchema.parse({
@@ -86,5 +149,17 @@ export async function getStats(env: Pick, days = 30)
outputTokens: row.output_tokens ?? 0,
})),
topRepos: topRepos.map((row) => ({ owner: row.owner, repo: row.repo, jobs: row.jobs })),
+ // Drop any rows whose enum-typed column holds an unexpected value (e.g. legacy
+ // rows back-migrated from JSON, where severity/category have no DB CHECK
+ // constraint). Keeping them would fail statsSchema.parse and 500 the endpoint.
+ statuses: statusRows.filter((row) => jobStatusSet.has(row.status)).map((row) => ({ status: row.status as (typeof jobStatuses)[number], count: row.count })),
+ triggers: triggerRows.filter((row) => reviewTriggerSet.has(row.trigger)).map((row) => ({ trigger: row.trigger as (typeof reviewTriggers)[number], count: row.count })),
+ severities: severityRows.filter((row) => reviewSeveritySet.has(row.severity)).map((row) => ({ severity: row.severity as (typeof reviewSeverities)[number], count: row.count })),
+ categories: categoryRows.filter((row) => reviewCategorySet.has(row.category)).map((row) => ({ category: row.category as (typeof reviewCategories)[number], count: row.count })),
+ performance: {
+ avgDurationMs: performanceRow?.avg_duration_ms != null ? Math.round(performanceRow.avg_duration_ms) : null,
+ p95DurationMs: performanceRow?.p95_duration_ms != null ? Math.round(performanceRow.p95_duration_ms) : null,
+ avgConfidence: performanceRow?.avg_confidence ?? null,
+ },
});
}
diff --git a/src/server/env.ts b/src/server/env.ts
index 0ab9fc6..e15e2a3 100644
--- a/src/server/env.ts
+++ b/src/server/env.ts
@@ -1,7 +1,7 @@
import type { ReviewJobMessage } from '@shared/schema';
export interface WorkersAiBinding {
- run(model: string, input: Record): Promise;
+ run(model: string, input: Record, options?: { signal?: AbortSignal }): Promise;
}
export interface QueueProducer {
diff --git a/src/server/index.ts b/src/server/index.ts
index 94ce8a2..3de712c 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -17,6 +17,12 @@ export default {
return runWithDb(env, () => app.fetch(request, env, ctx));
},
+ async scheduled(_controller: ScheduledController, env: AppBindings, _ctx: ExecutionContext) {
+ return runWithDb(env, async () => {
+ await runBestEffortJobMaintenance(env);
+ });
+ },
+
async queue(batch: MessageBatch, env: AppBindings, _ctx: ExecutionContext) {
return runWithDb(env, async () => {
try {
diff --git a/src/server/models/anthropic.ts b/src/server/models/anthropic.ts
index dab3da4..423bf3d 100644
--- a/src/server/models/anthropic.ts
+++ b/src/server/models/anthropic.ts
@@ -2,7 +2,7 @@ import { logger } from '@server/core/logger';
import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types';
-const ANTHROPIC_TIMEOUT_MS = 180_000;
+const ANTHROPIC_TIMEOUT_MS = 80_000;
const ANTHROPIC_MAX_OUTPUT_TOKENS = 4096;
const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1';
@@ -15,16 +15,17 @@ export interface AnthropicResponse {
}
export async function reviewWithAnthropic(
- config: { apiKey: string; baseUrl?: string | null; providerName: string },
+ config: { apiKey: string; baseUrl?: string | null; providerName: string; timeoutMs?: number },
model: string,
input: { systemPrompt: string; userPrompt: string },
tracker?: { incrementSubrequests(count?: number): void },
): Promise {
logger.info(`Calling Anthropic model: ${model}`);
const baseUrl = (config.baseUrl || DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, '');
+ const timeoutMs = config.timeoutMs ?? ANTHROPIC_TIMEOUT_MS;
if (tracker) tracker.incrementSubrequests(1);
- const response = await withTimeout('Anthropic API', ANTHROPIC_TIMEOUT_MS, (signal) =>
+ const response = await withTimeout('Anthropic API', timeoutMs, (signal) =>
fetch(`${baseUrl}/messages`, {
method: 'POST',
signal,
diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts
index 8436c75..91a1670 100644
--- a/src/server/models/cloudflare.ts
+++ b/src/server/models/cloudflare.ts
@@ -3,8 +3,15 @@ import type { AppBindings } from '@server/env';
import { TimeoutError } from '@server/core/timeout';
import { ProviderRequestError, type ModelResponse } from './types';
-/** Max wall-clock time allowed for a single Workers-AI call. */
-const CLOUDFLARE_TIMEOUT_MS = 180_000;
+/**
+ * Default max wall-clock time allowed for a single Workers-AI call when the caller doesn't
+ * supply a diff-size-aware budget. Kept well under the review workflow's 15-minute step
+ * timeout: a model that hasn't answered a code-review prompt in this long (reasoning models
+ * under strict-JSON decoding are the usual offenders -- they burn the whole token budget
+ * "thinking" and never emit the JSON) is not going to, so we fail fast and let the file defer
+ * to a fresh invocation instead of stalling the whole review.
+ */
+const CLOUDFLARE_TIMEOUT_MS = 45_000;
const CLOUDFLARE_MAX_RETRIES = 0;
const CLOUDFLARE_MAX_OUTPUT_TOKENS = 8192;
const REVIEW_RESPONSE_SCHEMA = {
@@ -161,15 +168,25 @@ export async function reviewWithCloudflare(
input: { systemPrompt: string; userPrompt: string },
tracker?: { incrementSubrequests(count?: number): void },
providerName = 'Cloudflare',
+ options?: { timeoutMs?: number },
): Promise {
const maxRetries = CLOUDFLARE_MAX_RETRIES;
+ const timeoutMs = options?.timeoutMs ?? CLOUDFLARE_TIMEOUT_MS;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let timer: ReturnType | undefined;
+ // Abort the underlying Workers-AI request when the timeout fires. Promise.race on its own
+ // only stops *us* awaiting -- the subrequest would keep running in the background, holding
+ // this invocation's wall-clock and pushing the workflow toward its 15-minute step cap long
+ // after we've given up. Aborting via the AI binding's signal actually cancels it.
+ const controller = new AbortController();
const timeoutPromise = new Promise((_, reject) => {
- timer = setTimeout(() => reject(new TimeoutError(`Cloudflare (${model})`, CLOUDFLARE_TIMEOUT_MS)), CLOUDFLARE_TIMEOUT_MS);
+ timer = setTimeout(() => {
+ controller.abort();
+ reject(new TimeoutError(`Cloudflare (${model})`, timeoutMs));
+ }, timeoutMs);
});
try {
@@ -182,29 +199,30 @@ export async function reviewWithCloudflare(
logger.info(`Calling Cloudflare model: ${model}`);
const startTime = Date.now();
- const result = await Promise.race([
- env.AI.run(model as any, {
- messages: [
- {
- role: 'system',
- content: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`,
- },
- { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` },
- ],
- max_completion_tokens: CLOUDFLARE_MAX_OUTPUT_TOKENS,
- response_format: {
- type: 'json_schema',
- json_schema: {
- name: 'codra_file_review',
- strict: true,
- schema: REVIEW_RESPONSE_SCHEMA,
- },
+ const runPromise = env.AI.run(model as any, {
+ messages: [
+ {
+ role: 'system',
+ content: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`,
},
- temperature: 0,
- top_p: 0.1,
- }),
- timeoutPromise,
- ]);
+ { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` },
+ ],
+ max_completion_tokens: CLOUDFLARE_MAX_OUTPUT_TOKENS,
+ response_format: {
+ type: 'json_schema',
+ json_schema: {
+ name: 'codra_file_review',
+ strict: true,
+ schema: REVIEW_RESPONSE_SCHEMA,
+ },
+ },
+ temperature: 0,
+ top_p: 0.1,
+ }, { signal: controller.signal });
+ // Once the timeout wins the race the aborted run still settles (as a rejection); attach a
+ // no-op handler so that late rejection can't surface as an unhandled promise rejection.
+ runPromise.catch(() => {});
+ const result = await Promise.race([runPromise, timeoutPromise]);
const durationMs = Date.now() - startTime;
logger.info(`AI model ${model} responded in ${durationMs}ms`);
diff --git a/src/server/models/google.ts b/src/server/models/google.ts
index 085d182..a8dda6a 100644
--- a/src/server/models/google.ts
+++ b/src/server/models/google.ts
@@ -2,14 +2,41 @@ import { logger } from '@server/core/logger';
import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types';
-/** Max wall-clock time allowed for a single Google AI Studio call. */
-const GEMINI_TIMEOUT_MS = 180_000;
+/** Default max wall-clock time for a single Google AI Studio call when the caller doesn't
+ * supply a diff-size-aware budget. */
+const GEMINI_TIMEOUT_MS = 45_000;
const GEMINI_MAX_RETRIES = 1;
const GEMINI_MAX_OUTPUT_TOKENS = 4096;
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
function isRetryableGeminiStatus(status: number) {
- return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524;
+ return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524;
+}
+
+function defaultRetryDelayMs(attempt: number) {
+ return Math.pow(2, attempt + 1) * 1000 + Math.random() * 1000;
+}
+
+function retryAfterDelayMs(value: string | null) {
+ if (!value) return null;
+ const seconds = Number(value);
+ if (Number.isFinite(seconds) && seconds >= 0) {
+ return seconds * 1000;
+ }
+
+ const dateMs = Date.parse(value);
+ if (Number.isFinite(dateMs)) {
+ return Math.max(0, dateMs - Date.now());
+ }
+
+ return null;
+}
+
+function isRetryableTransportError(error: unknown) {
+ if (!(error instanceof Error)) return false;
+ if (error.name === 'TimeoutError' || error.message.toLowerCase().includes('timeout')) return true;
+ if (error.message.includes('fetch failed')) return true;
+ return error instanceof TypeError;
}
function isPrivateIP(hostname: string) {
@@ -39,11 +66,12 @@ function isValidPublicUrl(urlString: string) {
}
export async function reviewWithGoogle(
- config: { apiKey: string; baseUrl?: string | null; providerName?: string },
+ config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number },
model: string,
input: { systemPrompt: string; userPrompt: string },
tracker?: { incrementSubrequests(count?: number): void },
): Promise {
+ const timeoutMs = config.timeoutMs ?? GEMINI_TIMEOUT_MS;
logger.info(`Calling Google model: ${model}`);
if (config.baseUrl && !isValidPublicUrl(config.baseUrl)) {
@@ -55,17 +83,19 @@ export async function reviewWithGoogle(
const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`;
const maxRetries = GEMINI_MAX_RETRIES;
let lastError: unknown;
+ let delayBeforeAttemptMs = 0;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ if (delayBeforeAttemptMs > 0) {
+ logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delayBeforeAttemptMs)}ms`);
+ await new Promise(resolve => setTimeout(resolve, delayBeforeAttemptMs));
+ delayBeforeAttemptMs = 0;
+ }
+
+ let response: Response;
try {
if (tracker) tracker.incrementSubrequests(1);
- if (attempt > 0) {
- const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
- logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delay)}ms`);
- await new Promise(resolve => setTimeout(resolve, delay));
- }
-
- const response = await withTimeout('Gemini API', GEMINI_TIMEOUT_MS, (signal) =>
+ response = await withTimeout('Gemini API', timeoutMs, (signal) =>
fetch(url, {
method: 'POST',
signal,
@@ -84,64 +114,69 @@ export async function reviewWithGoogle(
},
],
generationConfig: {
- responseMimeType: 'application/json',
+ ...(model.toLowerCase().includes('gemma') ? {} : { responseMimeType: 'application/json' }),
maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS,
},
}),
}),
);
-
- if (!response.ok) {
- const errorText = await response.text();
- const message = providerErrorMessage(errorText);
- const isRetryable = isRetryableGeminiStatus(response.status);
-
- const logData = {
- error: message,
- attempt,
- willRetry: isRetryable && attempt < maxRetries,
- };
- if (isRetryable && attempt < maxRetries) {
- logger.warn(`Gemini request failed with ${response.status}; retrying`, logData);
- lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message);
- continue;
- }
-
- logger.error(`Gemini request failed with ${response.status}`, logData);
- throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message);
+ } catch (error) {
+ lastError = error;
+ if (isRetryableTransportError(error) && attempt < maxRetries) {
+ delayBeforeAttemptMs = defaultRetryDelayMs(attempt);
+ continue;
}
+ throw error;
+ }
- const durationMs = Date.now() - startTime;
- logger.info(`AI model ${model} responded in ${durationMs}ms`);
-
- const data = (await response.json()) as {
- candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
- usageMetadata?: {
- promptTokenCount?: number;
- candidatesTokenCount?: number;
- };
+ if (!response.ok) {
+ const errorText = await response.text();
+ const message = providerErrorMessage(errorText);
+ const isRetryable = isRetryableGeminiStatus(response.status);
+ const retryDelayMs = response.status === 429
+ ? retryAfterDelayMs(response.headers.get('retry-after')) ?? defaultRetryDelayMs(attempt)
+ : defaultRetryDelayMs(attempt);
+
+ const logData = {
+ error: message,
+ attempt,
+ willRetry: isRetryable && attempt < maxRetries,
+ retryDelayMs: isRetryable && attempt < maxRetries ? retryDelayMs : undefined,
};
-
- const rawText = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('')?.trim();
- if (!rawText) {
- throw new Error('Gemini returned an empty response.');
+ if (isRetryable && attempt < maxRetries) {
+ logger.warn(`Gemini request failed with ${response.status}; retrying`, logData);
+ lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message);
+ delayBeforeAttemptMs = retryDelayMs;
+ continue;
}
- return {
- rawText,
- inputTokens: data.usageMetadata?.promptTokenCount ?? 0,
- outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0,
- modelUsed: model,
- provider: config.providerName ?? 'Google',
+ logger.error(`Gemini request failed with ${response.status}`, logData);
+ throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message);
+ }
+
+ const durationMs = Date.now() - startTime;
+ logger.info(`AI model ${model} responded in ${durationMs}ms`);
+
+ const data = (await response.json()) as {
+ candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
+ usageMetadata?: {
+ promptTokenCount?: number;
+ candidatesTokenCount?: number;
};
- } catch (error) {
- lastError = error;
- const isTimeout = error instanceof Error && (error.name === 'TimeoutError' || error.message.includes('timeout'));
- if (isTimeout && attempt < maxRetries) {
- continue;
- }
- throw error;
+ };
+
+ const rawText = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('')?.trim();
+ if (!rawText) {
+ throw new Error('Gemini returned an empty response.');
}
+
+ return {
+ rawText,
+ inputTokens: data.usageMetadata?.promptTokenCount ?? 0,
+ outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0,
+ modelUsed: model,
+ provider: config.providerName ?? 'Google',
+ };
}
throw lastError;
diff --git a/src/server/models/limits.ts b/src/server/models/limits.ts
new file mode 100644
index 0000000..3ef41a2
--- /dev/null
+++ b/src/server/models/limits.ts
@@ -0,0 +1,84 @@
+/**
+ * Shared throttling/timeout policy for outbound model calls.
+ *
+ * Two hard Cloudflare Workers Free-plan constraints shape everything here:
+ *
+ * 1. Each Worker invocation may have at most SIX connections simultaneously waiting for
+ * response headers. Anything beyond that (fetch, AI binding, KV, Hyperdrive) is silently
+ * QUEUED by the runtime -- it does not error, it just doesn't start. A model call that sits
+ * queued behind other long-running model calls will burn its own client-side timeout without
+ * the request ever being dispatched, which shows up in logs as a provider "timing out" at
+ * exactly the configured timeout on every attempt.
+ * 2. 50 subrequests per invocation, so retries are expensive and every queued-then-timed-out
+ * call is a wasted subrequest.
+ */
+
+/** Base wall-clock budget for a model call reviewing a small diff. Successful review calls in
+ * production land in the ~10-50s range; anything slower on a small prompt is almost always a
+ * stuck/overloaded model, so fail over to the next model quickly instead of stalling the chunk. */
+export const MODEL_TIMEOUT_BASE_MS = 45_000;
+/** Extra time granted per diff line beyond MODEL_TIMEOUT_FREE_LINES -- large diffs legitimately
+ * need longer generations. */
+export const MODEL_TIMEOUT_PER_LINE_MS = 100;
+/** Diff lines included in the base budget before per-line scaling kicks in. */
+export const MODEL_TIMEOUT_FREE_LINES = 100;
+/** Ceiling regardless of diff size; keeps a single call well under the workflow's 15-minute
+ * step timeout even with several fallbacks. */
+export const MODEL_TIMEOUT_MAX_MS = 120_000;
+
+/**
+ * Wall-clock timeout for one model call, scaled by the size of the (already truncated) diff
+ * the model has to review. Small diffs fail over fast; large diffs get up to 2 minutes.
+ */
+export function adaptiveModelTimeoutMs(diffLineCount: number | null | undefined): number {
+ const lines = typeof diffLineCount === 'number' && Number.isFinite(diffLineCount) ? Math.max(0, diffLineCount) : 0;
+ const scaled = MODEL_TIMEOUT_BASE_MS + Math.max(0, lines - MODEL_TIMEOUT_FREE_LINES) * MODEL_TIMEOUT_PER_LINE_MS;
+ return Math.min(MODEL_TIMEOUT_MAX_MS, scaled);
+}
+
+/**
+ * Max model calls in flight at once for a single invocation. Kept below the runtime's
+ * 6-connection cap so short-lived KV/Hyperdrive/GitHub requests issued by concurrent file
+ * reviews still have free connection slots and model calls are never queued behind each other
+ * by the runtime (queued calls burn their timeout without ever being dispatched).
+ */
+export const MAX_CONCURRENT_MODEL_CALLS = 3;
+
+/**
+ * Tiny FIFO semaphore. Callers wait *before* their provider timeout starts, so waiting for a
+ * slot never eats into a model call's own time budget.
+ */
+export class ModelCallGate {
+ private active = 0;
+ private readonly waiters: Array<() => void> = [];
+
+ constructor(private readonly limit = MAX_CONCURRENT_MODEL_CALLS) {}
+
+ async run(fn: () => Promise): Promise {
+ await this.acquire();
+ try {
+ return await fn();
+ } finally {
+ this.release();
+ }
+ }
+
+ private acquire(): Promise {
+ if (this.active < this.limit) {
+ this.active++;
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => this.waiters.push(resolve));
+ }
+
+ private release() {
+ // Hand the slot directly to the next waiter (active count unchanged) so a newly arriving
+ // caller can't sneak in between the release and the waiter resuming.
+ const next = this.waiters.shift();
+ if (next) {
+ next();
+ } else {
+ this.active--;
+ }
+ }
+}
diff --git a/src/server/models/openai.ts b/src/server/models/openai.ts
index 11f4540..aa13ab6 100644
--- a/src/server/models/openai.ts
+++ b/src/server/models/openai.ts
@@ -2,7 +2,7 @@ import { logger } from '@server/core/logger';
import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types';
-const OPENAI_TIMEOUT_MS = 180_000;
+const OPENAI_TIMEOUT_MS = 80_000;
const OPENAI_MAX_OUTPUT_TOKENS = 4096;
export interface OpenAIResponse {
@@ -63,12 +63,13 @@ function isValidPublicUrl(urlString: string) {
}
export async function reviewWithOpenAI(
- config: { apiKey: string | null; baseUrl: string; providerName: string },
+ config: { apiKey: string | null; baseUrl: string; providerName: string; timeoutMs?: number },
model: string,
input: { systemPrompt: string; userPrompt: string },
tracker?: { incrementSubrequests(count?: number): void },
): Promise {
logger.info(`Calling OpenAI-format model: ${model}`);
+ const timeoutMs = config.timeoutMs ?? OPENAI_TIMEOUT_MS;
if (!isValidPublicUrl(config.baseUrl)) {
throw new ProviderRequestError(config.providerName, 400, 'Invalid provider base URL.');
@@ -77,7 +78,7 @@ export async function reviewWithOpenAI(
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
if (tracker) tracker.incrementSubrequests(1);
- const response = await withTimeout('OpenAI API', OPENAI_TIMEOUT_MS, (signal) =>
+ const response = await withTimeout('OpenAI API', timeoutMs, (signal) =>
fetch(url, {
method: 'POST',
signal,
diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts
index 8d8423e..897c291 100644
--- a/src/server/routes/api/models.ts
+++ b/src/server/routes/api/models.ts
@@ -28,7 +28,6 @@ import { ProviderRequestError } from '@server/models/types';
const apiFormatSchema = z.enum(llmApiFormats);
const positiveIntegerSchema = z.number().int().positive().finite();
-const optionalLimitSchema = positiveIntegerSchema.nullable();
const modelIdSchema = z.string().trim().min(1);
const optionalUrlSchema = z.string().trim().url().nullable().optional();
const providerIdSchema = z.string().uuid();
@@ -48,9 +47,6 @@ const providerUpdateSchema = providerCreateSchema.extend({
const modelConfigUpdateSchema = z.object({
providerId: providerIdSchema,
modelName: z.string().trim().min(1),
- rpm: optionalLimitSchema,
- tpm: optionalLimitSchema,
- rpd: optionalLimitSchema,
}).strict();
const globalModelConfigSchema = z.object({
diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts
new file mode 100644
index 0000000..774ffdc
--- /dev/null
+++ b/src/server/routes/api/settings.ts
@@ -0,0 +1,41 @@
+import { Hono } from 'hono';
+import { z } from 'zod';
+import type { AppEnv } from '@server/env';
+import { getReviewSettings, updateReviewSettings } from '@server/db/app-settings';
+import { jsonError } from '@server/core/http';
+import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewSettingsSchema } from '@shared/schema';
+
+const reviewSettingsPatchSchema = z.object({
+ concurrencyLevel: z.enum(reviewConcurrencyLevels).optional(),
+ maxComments: z.number().int().refine(
+ (value) => (reviewMaxCommentsOptions as readonly number[]).includes(value),
+ 'Invalid max comments value.',
+ ).optional(),
+}).strict().refine(
+ (settings) => settings.concurrencyLevel !== undefined || settings.maxComments !== undefined,
+ 'At least one setting must be provided.',
+);
+
+export function createSettingsRouter() {
+ const app = new Hono();
+
+ app.get('/', async (c) => {
+ const settings = await getReviewSettings(c.env);
+ return c.json({ settings });
+ });
+
+ app.patch('/', async (c) => {
+ const body = await c.req.json().catch(() => null);
+ const parsed = reviewSettingsPatchSchema.safeParse(body);
+ if (!parsed.success) {
+ return jsonError('Invalid review settings.', 400);
+ }
+
+ const current = await getReviewSettings(c.env);
+ const next = reviewSettingsSchema.parse({ ...current, ...parsed.data });
+ await updateReviewSettings(c.env, next);
+ return c.json({ ok: true, settings: next });
+ });
+
+ return app;
+}
diff --git a/src/server/services/model.ts b/src/server/services/model.ts
index 144a9a2..dafe1da 100644
--- a/src/server/services/model.ts
+++ b/src/server/services/model.ts
@@ -14,6 +14,7 @@ import { logger } from '../core/logger';
import { normalizeModelId } from '@shared/schema';
import { getResolvedModelConfig, type ResolvedModelConfig } from '@server/db/model-configs';
import { decryptLlmApiKey } from '@server/core/llm-crypto';
+import { ModelCallGate, adaptiveModelTimeoutMs } from '../models/limits';
const PROVIDER_UNAVAILABLE_TTL_SECONDS = 24 * 60 * 60;
const COMPACT_REVIEW_PROMPT_LINE_CAP = 400;
@@ -84,6 +85,30 @@ function isTransientModelFailure(error: unknown) {
}
export class ModelService {
+ // Model configs don't change during a single review invocation, but resolveModel() is called
+ // once per file *and* once per fallback model. Left uncached that's a Hyperdrive round-trip
+ // (a counted subrequest) for every one of those, which both burns the per-invocation
+ // subrequest budget (shrinking how many files a chunk can review in parallel) and floods the
+ // connection pool. Memoize per ModelService instance (one instance == one invocation/chunk)
+ // so each distinct model is resolved from the DB at most once. Cache the in-flight promise
+ // (not just the settled value) so concurrent resolveModel() calls for the same model made
+ // before the first DB round-trip completes all await the same request instead of each firing
+ // their own.
+ private readonly resolvedModelCache = new Map>();
+
+ // The Workers runtime allows only 6 simultaneous connections per invocation; anything beyond
+ // that is queued without starting. When several files review in parallel, un-gated model
+ // calls queue behind each other and burn their entire client timeout before the request is
+ // even dispatched (observed as a provider "timing out" at exactly the configured timeout on
+ // every attempt). Gate all outbound model calls for this invocation so a call's timeout only
+ // starts once it actually has a connection slot.
+ private readonly callGate = new ModelCallGate();
+
+ // Provider-unavailable markers live in KV and every read is a counted subrequest. The marker
+ // can't flip from set back to unset within one invocation, so cache lookups per instance
+ // (one instance == one invocation) instead of re-reading KV for every file in the chunk.
+ private readonly providerUnavailableCache = new Map>();
+
constructor(
private env: AppBindings,
private tracker?: TokenTracker,
@@ -94,25 +119,37 @@ export class ModelService {
return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${providerId}` : null;
}
- private async isProviderUnavailable(providerId: string) {
+ private isProviderUnavailable(providerId: string): Promise {
const key = this.providerUnavailableKey(providerId);
- if (!key) return false;
+ if (!key) return Promise.resolve(false);
- try {
- return (await this.env.APP_KV.get(key)) !== null;
- } catch (error) {
- logger.warn(`Failed to read unavailable provider marker for ${providerId}`, {
- error: error instanceof Error ? error.message : String(error),
- });
- return false;
+ let pending = this.providerUnavailableCache.get(providerId);
+ if (!pending) {
+ pending = (async () => {
+ try {
+ this.tracker?.incrementSubrequests(1);
+ return (await this.env.APP_KV.get(key)) !== null;
+ } catch (error) {
+ logger.warn(`Failed to read unavailable provider marker for ${providerId}`, {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return false;
+ }
+ })();
+ this.providerUnavailableCache.set(providerId, pending);
}
+ return pending;
}
private async markProviderUnavailable(providerId: string, reason: string) {
const key = this.providerUnavailableKey(providerId);
if (!key) return;
+ // Keep the in-invocation cache consistent with what we just wrote.
+ this.providerUnavailableCache.set(providerId, Promise.resolve(true));
+
try {
+ this.tracker?.incrementSubrequests(1);
await this.env.APP_KV.put(
key,
JSON.stringify({
@@ -161,7 +198,17 @@ export class ModelService {
private async resolveModel(model: string) {
const normalized = normalizeModel(model);
- const resolved = await getResolvedModelConfig(this.env, normalized);
+ let pending = this.resolvedModelCache.get(normalized);
+ if (!pending) {
+ // Cache the DB answer -- including a null "not configured" result -- so a missing or
+ // repeatedly-used model isn't re-queried for every file in the chunk.
+ pending = getResolvedModelConfig(this.env, normalized);
+ this.resolvedModelCache.set(normalized, pending);
+ // A failed lookup (e.g. a transient DB error) shouldn't poison the cache for the rest of
+ // the invocation -- drop it so the next call retries instead of rejecting immediately.
+ pending.catch(() => this.resolvedModelCache.delete(normalized));
+ }
+ const resolved = await pending;
if (!resolved) {
throw new Error(`Model ${normalized} is not configured. Add it in Settings before using it in a route.`);
}
@@ -183,43 +230,59 @@ export class ModelService {
private async callResolvedModel(
config: ResolvedModelConfig,
input: { systemPrompt: string; userPrompt: string },
+ timeoutMs?: number,
): Promise {
+ // Resolve credentials *before* taking a gate slot so slow KV/crypto work never occupies a
+ // model-call slot, then run the actual provider request under the gate. The provider's
+ // timeout only starts inside the gated call, so time spent waiting for a slot is free.
if (config.apiFormat === 'cloudflare-workers-ai') {
- return reviewWithCloudflare(this.env, config.modelName, input, this.tracker, config.providerName);
+ return this.callGate.run(() =>
+ reviewWithCloudflare(this.env, config.modelName, input, this.tracker, config.providerName, { timeoutMs }),
+ );
}
if (config.apiFormat === 'gemini') {
- return reviewWithGoogle(
- { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName },
- config.modelName,
- input,
- this.tracker,
+ const apiKey = await this.decryptApiKey(config);
+ return this.callGate.run(() =>
+ reviewWithGoogle(
+ { apiKey, baseUrl: config.baseUrl, providerName: config.providerName, timeoutMs },
+ config.modelName,
+ input,
+ this.tracker,
+ ),
);
}
if (config.apiFormat === 'openai') {
- return reviewWithOpenAI(
- {
- apiKey: await this.decryptApiKey(config),
- baseUrl: config.baseUrl || 'https://api.openai.com/v1',
- providerName: config.providerName,
- },
- config.modelName,
- input,
- this.tracker,
+ const apiKey = await this.decryptApiKey(config);
+ return this.callGate.run(() =>
+ reviewWithOpenAI(
+ {
+ apiKey,
+ baseUrl: config.baseUrl || 'https://api.openai.com/v1',
+ providerName: config.providerName,
+ timeoutMs,
+ },
+ config.modelName,
+ input,
+ this.tracker,
+ ),
);
}
- return reviewWithAnthropic(
- { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName },
- config.modelName,
- input,
- this.tracker,
+ const apiKey = await this.decryptApiKey(config);
+ return this.callGate.run(() =>
+ reviewWithAnthropic(
+ { apiKey, baseUrl: config.baseUrl, providerName: config.providerName, timeoutMs },
+ config.modelName,
+ input,
+ this.tracker,
+ ),
);
}
- private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }): Promise {
- return this.callResolvedModel(await this.resolveModel(model), input);
+ private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }, timeoutMs?: number): Promise {
+ return this.callResolvedModel(await this.resolveModel(model), input, timeoutMs);
}
async reviewFile(params: {
@@ -247,10 +310,30 @@ export class ModelService {
});
const modelsToTry = [primary, ...fallbacks];
+ // Size the per-call timeout to the diff the model actually sees (post-truncation): small
+ // files fail over to the next model fast; large diffs get a proportionally longer budget.
+ const timeoutMs = adaptiveModelTimeoutMs(reviewFile.lineCount);
+
let lastError: unknown;
let lastTransientError: unknown;
let sawTransientFailure = false;
- for (const currentModel of modelsToTry) {
+ for (const [modelIndex, currentModel] of modelsToTry.entries()) {
+ // Always allow the first (primary) model a shot even if the shared job budget is
+ // already tight, so a file isn't punished for other files' earlier failures. But once
+ // we're into the fallback chain, each additional attempt costs more subrequests
+ // (config lookup + provider call, sometimes a provider-availability check too) that
+ // could tip this whole invocation over Cloudflare's per-invocation subrequest cap
+ // (Workers Free plan: 50). Defer the file for a later retry instead of gambling the
+ // rest of the invocation's budget on a low-probability extra fallback.
+ if (modelIndex > 0 && this.tracker?.isNearLimit()) {
+ logger.warn(`Skipping remaining fallback models for ${params.file.path}; subrequest budget for this invocation is nearly exhausted`, {
+ skippedModels: modelsToTry.slice(modelIndex),
+ });
+ sawTransientFailure = true;
+ lastTransientError = lastTransientError ?? lastError ?? new Error('Subrequest budget for this invocation was nearly exhausted before trying all configured fallback models');
+ break;
+ }
+
let resolved: ResolvedModelConfig;
try {
resolved = await this.resolveModel(currentModel);
@@ -272,7 +355,7 @@ export class ModelService {
while (attempts < maxAttempts) {
try {
- const response = await this.callResolvedModel(resolved, { systemPrompt, userPrompt });
+ const response = await this.callResolvedModel(resolved, { systemPrompt, userPrompt }, timeoutMs);
if (this.tracker) {
this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens);
@@ -361,7 +444,7 @@ export class ModelService {
const response = await this.callResolvedModel(resolved, {
systemPrompt: SUMMARY_SYSTEM_PROMPT,
userPrompt: buildSummaryPrompt(params),
- });
+ }, adaptiveModelTimeoutMs(0));
if (this.tracker) {
this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens);
diff --git a/src/server/workflows/review.ts b/src/server/workflows/review.ts
index 7ffddf9..51af5a8 100644
--- a/src/server/workflows/review.ts
+++ b/src/server/workflows/review.ts
@@ -5,9 +5,18 @@ import { type ReviewJobMessage } from '@shared/schema';
import { setJobWorkflowInstance } from '@server/db/jobs';
import { logger } from '@server/core/logger';
import { runBestEffortJobMaintenance } from '@server/core/job-recovery';
+import { runWithDb } from '@server/db/client';
export class ReviewWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent, step: WorkflowStep) {
+ // Share one DB client/connection for this entire invocation instead of opening a
+ // new Hyperdrive connection per query (the default behavior of getDb() outside any
+ // runWithDb() context). On workflow replay after a step.sleep or a resumed retry,
+ // this simply runs again and creates a fresh client for that new invocation.
+ return runWithDb(this.env, () => this.execute(event, step));
+ }
+
+ private async execute(event: WorkflowEvent, step: WorkflowStep) {
const params = event.payload;
const env = this.env;
@@ -86,6 +95,11 @@ export class ReviewWorkflow extends WorkflowEntrypoint {
await runBestEffortJobMaintenance(env);
diff --git a/src/shared/schema.ts b/src/shared/schema.ts
index 0e39d13..a11c5ed 100644
--- a/src/shared/schema.ts
+++ b/src/shared/schema.ts
@@ -302,6 +302,35 @@ export const statsSchema = z.object({
jobs: z.number().int(),
}),
),
+ statuses: z.array(
+ z.object({
+ status: z.enum(jobStatuses),
+ count: z.number().int(),
+ }),
+ ),
+ triggers: z.array(
+ z.object({
+ trigger: z.enum(reviewTriggers),
+ count: z.number().int(),
+ }),
+ ),
+ severities: z.array(
+ z.object({
+ severity: z.enum(reviewSeverities),
+ count: z.number().int(),
+ }),
+ ),
+ categories: z.array(
+ z.object({
+ category: z.enum(reviewCategories),
+ count: z.number().int(),
+ }),
+ ),
+ performance: z.object({
+ avgDurationMs: z.number().nullable(),
+ p95DurationMs: z.number().nullable(),
+ avgConfidence: z.number().nullable(),
+ }),
});
export type ParsedReviewComment = z.infer;
@@ -365,9 +394,6 @@ export const modelConfigSchema = z.object({
providerName: z.string(),
apiFormat: z.enum(llmApiFormats),
modelName: z.string(),
- rpm: z.number().int().nullable(),
- tpm: z.number().int().nullable(),
- rpd: z.number().int().nullable(),
updatedAt: dateStringSchema,
});
@@ -377,3 +403,20 @@ export type ModelConfig = z.infer;
export type StatsPayload = z.infer;
export const defaultRepoConfig = repoConfigSchema.parse({});
+
+export const reviewConcurrencyLevels = ['low', 'medium', 'high', 'max'] as const;
+export type ReviewConcurrencyLevel = typeof reviewConcurrencyLevels[number];
+export const REVIEW_CONCURRENCY_LIMITS: Record = {
+ low: 1,
+ medium: 2,
+ high: 3,
+ max: 4,
+};
+
+export const reviewMaxCommentsOptions = [5, 10, 15, 20] as const;
+
+export const reviewSettingsSchema = z.object({
+ concurrencyLevel: z.enum(reviewConcurrencyLevels).default('medium'),
+ maxComments: z.union([z.literal(5), z.literal(10), z.literal(15), z.literal(20)]).default(10),
+});
+export type ReviewSettings = z.infer;
diff --git a/test/api.spec.ts b/test/api.spec.ts
index 27e3d15..c9e70f1 100644
--- a/test/api.spec.ts
+++ b/test/api.spec.ts
@@ -1,6 +1,7 @@
import { createApp } from '@server/app';
import { getJobForProcessing, insertJob } from '@server/db/jobs';
import { insertFileReview } from '@server/db/file-reviews';
+import { queryRows } from '@server/db/client';
import { getRepoConfigRecord } from '@server/db/repo-configs';
import { loadRepoConfig, updateGlobalConfig } from '@server/core/config';
import { GitHubClient } from '@server/core/github';
@@ -155,6 +156,126 @@ describe('Dashboard API Suite', () => {
expect(response.status).toBe(404);
});
+ it('preserves omitted review settings when patching a single setting', async () => {
+ const env = createTestEnv();
+ const token = await getAuthCookie(env);
+ const headers = {
+ Cookie: `codra_session=${token}`,
+ 'x-requested-with': 'XMLHttpRequest',
+ 'content-type': 'application/json',
+ };
+
+ try {
+ const seed = await app.request('/api/settings', {
+ method: 'PATCH',
+ headers,
+ body: JSON.stringify({ concurrencyLevel: 'low', maxComments: 5 }),
+ }, env);
+ expect(seed.status).toBe(200);
+
+ const commentsOnly = await app.request('/api/settings', {
+ method: 'PATCH',
+ headers,
+ body: JSON.stringify({ maxComments: 20 }),
+ }, env);
+ expect(commentsOnly.status).toBe(200);
+
+ const afterCommentsOnly = await app.request('/api/settings', {
+ headers: { Cookie: `codra_session=${token}` },
+ }, env);
+ expect(afterCommentsOnly.status).toBe(200);
+ await expect(afterCommentsOnly.json()).resolves.toMatchObject({
+ settings: { concurrencyLevel: 'low', maxComments: 20 },
+ });
+
+ const concurrencyOnly = await app.request('/api/settings', {
+ method: 'PATCH',
+ headers,
+ body: JSON.stringify({ concurrencyLevel: 'high' }),
+ }, env);
+ expect(concurrencyOnly.status).toBe(200);
+
+ const afterConcurrencyOnly = await app.request('/api/settings', {
+ headers: { Cookie: `codra_session=${token}` },
+ }, env);
+ expect(afterConcurrencyOnly.status).toBe(200);
+ await expect(afterConcurrencyOnly.json()).resolves.toMatchObject({
+ settings: { concurrencyLevel: 'high', maxComments: 20 },
+ });
+ } finally {
+ await app.request('/api/settings', {
+ method: 'PATCH',
+ headers,
+ body: JSON.stringify({ concurrencyLevel: 'medium', maxComments: 10 }),
+ }, env);
+ }
+ });
+
+ it('returns 400 for malformed review settings JSON', async () => {
+ const env = createTestEnv();
+ const token = await getAuthCookie(env);
+
+ const response = await app.request('/api/settings', {
+ method: 'PATCH',
+ headers: {
+ Cookie: `codra_session=${token}`,
+ 'x-requested-with': 'XMLHttpRequest',
+ 'content-type': 'application/json',
+ },
+ body: '{',
+ }, env);
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toMatchObject({ error: 'Invalid review settings.' });
+ });
+
+ it('falls back invalid stored review settings independently', async () => {
+ const env = createTestEnv();
+ const token = await getAuthCookie(env);
+
+ try {
+ await queryRows(
+ env,
+ `INSERT INTO global_settings (key, value) VALUES
+ ('review_concurrency_level', 'turbo'),
+ ('review_max_comments', '20')
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
+ );
+
+ const invalidConcurrency = await app.request('/api/settings', {
+ headers: { Cookie: `codra_session=${token}` },
+ }, env);
+ expect(invalidConcurrency.status).toBe(200);
+ await expect(invalidConcurrency.json()).resolves.toMatchObject({
+ settings: { concurrencyLevel: 'medium', maxComments: 20 },
+ });
+
+ await queryRows(
+ env,
+ `INSERT INTO global_settings (key, value) VALUES
+ ('review_concurrency_level', 'high'),
+ ('review_max_comments', '999')
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
+ );
+
+ const invalidComments = await app.request('/api/settings', {
+ headers: { Cookie: `codra_session=${token}` },
+ }, env);
+ expect(invalidComments.status).toBe(200);
+ await expect(invalidComments.json()).resolves.toMatchObject({
+ settings: { concurrencyLevel: 'high', maxComments: 10 },
+ });
+ } finally {
+ await queryRows(
+ env,
+ `INSERT INTO global_settings (key, value) VALUES
+ ('review_concurrency_level', 'medium'),
+ ('review_max_comments', '10')
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
+ );
+ }
+ });
+
it('rejects logout without the CSRF header', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);
@@ -375,9 +496,7 @@ describe('Dashboard API Suite', () => {
'content-type': 'application/json',
},
body: JSON.stringify({
- rpm: -1,
- tpm: Number.NaN,
- rpd: 100,
+ providerId: 'not-a-uuid',
provider: 'unknown',
}),
}, env);
@@ -491,7 +610,7 @@ describe('Dashboard API Suite', () => {
expect(response.status).toBe(200);
const data = await response.json() as ModelConfigsResponse;
const discoveredGoogleModel = data.configs.find(config => config.modelName === discoveredModelName);
- expect(discoveredGoogleModel).toMatchObject({ rpm: null, rpd: null, tpm: null });
+ expect(discoveredGoogleModel).toMatchObject({ modelName: discoveredModelName, apiFormat: 'gemini' });
expect(data.configs.some(config => config.providerName === 'Cloudflare' && config.modelName === '@cf/openai/gpt-oss-120b')).toBe(true);
expect(data.syncErrors).toEqual([]);
});
@@ -519,7 +638,7 @@ describe('Dashboard API Suite', () => {
const env = createTestEnv();
const token = await getAuthCookie(env);
await saveTestProviderApiKey(env);
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({
error: {
code: 429,
message: 'Quota exceeded. Please retry later.',
diff --git a/test/chunk-concurrency.spec.ts b/test/chunk-concurrency.spec.ts
new file mode 100644
index 0000000..87a12d2
--- /dev/null
+++ b/test/chunk-concurrency.spec.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { budgetAwareFileLimit } from '@server/core/review';
+import { TokenTracker } from '@server/core/token-tracker';
+import { REVIEW_CONCURRENCY_LIMITS, reviewConcurrencyLevels } from '@shared/schema';
+
+// Regression guard for the "concurrency slider is dead above medium" incident: the per-chunk
+// budget cap must NOT silently override the user's configured concurrency at a healthy budget.
+// These assertions exercise the REAL TokenTracker (so MAX_SUBREQUESTS / SAFE_MARGIN are in play)
+// and the REAL REVIEW_CONCURRENCY_LIMITS, so bumping SAFE_MARGIN or ESTIMATED_SUBREQUESTS_PER_FILE
+// back into a slider-defeating range fails this test.
+
+const maxLevel = Math.max(...reviewConcurrencyLevels.map((level) => REVIEW_CONCURRENCY_LIMITS[level]));
+
+describe('budgetAwareFileLimit', () => {
+ it('honors every configured concurrency level at a fresh budget', () => {
+ const fresh = new TokenTracker().remainingSafeBudget();
+ for (const level of reviewConcurrencyLevels) {
+ const configured = REVIEW_CONCURRENCY_LIMITS[level];
+ expect(budgetAwareFileLimit(fresh, configured)).toBe(configured);
+ }
+ });
+
+ it('still honors the highest level after the getPullRequest preamble spends a few subrequests', () => {
+ const tracker = new TokenTracker();
+ tracker.incrementSubrequests(3); // token read + getPullRequest + a little slack
+ expect(budgetAwareFileLimit(tracker.remainingSafeBudget(), maxLevel)).toBe(maxLevel);
+ });
+
+ it('throttles below the configured level only once the budget has actually been eaten into', () => {
+ // Deep into a troubled invocation the cap should shrink to protect the 50-subrequest ceiling.
+ expect(budgetAwareFileLimit(4, maxLevel)).toBe(0);
+ expect(budgetAwareFileLimit(0, maxLevel)).toBe(0);
+ expect(budgetAwareFileLimit(10, maxLevel)).toBeLessThan(maxLevel);
+ });
+
+ it('never exceeds the configured level even with a huge budget', () => {
+ expect(budgetAwareFileLimit(10_000, 2)).toBe(2);
+ });
+});
diff --git a/test/e2e/dashboard.spec.tsx b/test/e2e/dashboard.spec.tsx
index 4bc68dc..1245255 100644
--- a/test/e2e/dashboard.spec.tsx
+++ b/test/e2e/dashboard.spec.tsx
@@ -51,7 +51,12 @@ describe('Frontend UI Flows (JSDOM)', () => {
trend: [],
verdicts: [],
models: [],
- topRepos: []
+ topRepos: [],
+ statuses: [],
+ triggers: [],
+ severities: [],
+ categories: [],
+ performance: { avgDurationMs: null, p95DurationMs: null, avgConfidence: null },
}
});
diff --git a/test/model-config-cache.spec.ts b/test/model-config-cache.spec.ts
new file mode 100644
index 0000000..c0dae19
--- /dev/null
+++ b/test/model-config-cache.spec.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createTestEnv } from './helpers';
+
+// Isolated in its own file: mocking @server/db/model-configs module-wide would break the
+// other model-service tests that resolve configs against the real test DB.
+const getResolvedModelConfigMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@server/db/model-configs', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, getResolvedModelConfig: getResolvedModelConfigMock };
+});
+
+import { ModelService } from '@server/services/model';
+
+const cloudflareConfig = (modelId: string) => ({
+ modelId,
+ providerId: 'cf',
+ providerName: 'Cloudflare',
+ apiFormat: 'cloudflare-workers-ai' as const,
+ modelName: modelId,
+ updatedAt: new Date().toISOString(),
+ providerEnabled: true,
+ baseUrl: null,
+ encryptedApiKey: null,
+});
+
+describe('ModelService model-config caching', () => {
+ beforeEach(() => {
+ getResolvedModelConfigMock.mockReset();
+ });
+
+ it('resolves a given model config from the DB at most once per invocation', async () => {
+ getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId));
+ const service = new ModelService(createTestEnv());
+
+ // The same model is resolved repeatedly across a chunk (once per file); only the first
+ // should hit the DB.
+ await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash');
+ await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash');
+ await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash');
+
+ expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a separate cache entry per distinct model id', async () => {
+ getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId));
+ const service = new ModelService(createTestEnv());
+
+ await (service as any).resolveModel('gemma-4-31b-it');
+ await (service as any).resolveModel('gemma-4-26b-a4b-it');
+ await (service as any).resolveModel('gemma-4-31b-it');
+
+ expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('caches a null "not configured" result so it is not re-queried every file', async () => {
+ getResolvedModelConfigMock.mockResolvedValue(null);
+ const service = new ModelService(createTestEnv());
+
+ await expect((service as any).resolveModel('does-not-exist')).rejects.toThrow('is not configured');
+ await expect((service as any).resolveModel('does-not-exist')).rejects.toThrow('is not configured');
+
+ expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not share a cache across ModelService instances (one instance == one invocation)', async () => {
+ getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId));
+ const env = createTestEnv();
+
+ await (new ModelService(env) as any).resolveModel('@cf/zai-org/glm-4.7-flash');
+ await (new ModelService(env) as any).resolveModel('@cf/zai-org/glm-4.7-flash');
+
+ expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/test/model-limits.spec.ts b/test/model-limits.spec.ts
new file mode 100644
index 0000000..01f4582
--- /dev/null
+++ b/test/model-limits.spec.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import {
+ ModelCallGate,
+ adaptiveModelTimeoutMs,
+ MODEL_TIMEOUT_BASE_MS,
+ MODEL_TIMEOUT_MAX_MS,
+} from '../src/server/models/limits';
+
+describe('adaptiveModelTimeoutMs', () => {
+ it('uses the base budget for small diffs', () => {
+ expect(adaptiveModelTimeoutMs(0)).toBe(MODEL_TIMEOUT_BASE_MS);
+ expect(adaptiveModelTimeoutMs(100)).toBe(MODEL_TIMEOUT_BASE_MS);
+ expect(adaptiveModelTimeoutMs(undefined)).toBe(MODEL_TIMEOUT_BASE_MS);
+ expect(adaptiveModelTimeoutMs(null)).toBe(MODEL_TIMEOUT_BASE_MS);
+ });
+
+ it('scales with diff size beyond the free-line allowance', () => {
+ expect(adaptiveModelTimeoutMs(400)).toBe(MODEL_TIMEOUT_BASE_MS + 300 * 100);
+ expect(adaptiveModelTimeoutMs(400)).toBeGreaterThan(adaptiveModelTimeoutMs(200));
+ });
+
+ it('caps at the maximum regardless of diff size', () => {
+ expect(adaptiveModelTimeoutMs(100_000)).toBe(MODEL_TIMEOUT_MAX_MS);
+ });
+});
+
+describe('ModelCallGate', () => {
+ it('never runs more than the limit concurrently and eventually runs everything', async () => {
+ const gate = new ModelCallGate(2);
+ let active = 0;
+ let peak = 0;
+ const done: number[] = [];
+
+ const task = (id: number) =>
+ gate.run(async () => {
+ active++;
+ peak = Math.max(peak, active);
+ // Yield a couple of microtasks so tasks genuinely overlap.
+ await Promise.resolve();
+ await Promise.resolve();
+ active--;
+ done.push(id);
+ });
+
+ await Promise.all([task(1), task(2), task(3), task(4), task(5)]);
+
+ expect(peak).toBeLessThanOrEqual(2);
+ expect(done).toHaveLength(5);
+ });
+
+ it('releases the slot when a gated call rejects', async () => {
+ const gate = new ModelCallGate(1);
+
+ await expect(gate.run(async () => {
+ throw new Error('boom');
+ })).rejects.toThrow('boom');
+
+ // The slot must be free again for the next caller.
+ const result = await gate.run(async () => 'ok');
+ expect(result).toBe('ok');
+ });
+});
diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts
index 00dfea6..bf804cd 100644
--- a/test/model-service.spec.ts
+++ b/test/model-service.spec.ts
@@ -4,6 +4,7 @@ import { reviewWithCloudflare } from '@server/models/cloudflare';
import { reviewWithGoogle } from '@server/models/google';
import { createTestEnv, saveTestProviderApiKey } from './helpers';
import { defaultRepoConfig } from '@shared/schema';
+import { TokenTracker } from '@server/core/token-tracker';
describe('ModelService', () => {
afterEach(() => {
@@ -187,6 +188,66 @@ describe('ModelService', () => {
expect(response.rawText).toContain('"findings"');
});
+ it('honors Retry-After when retrying Google 429 responses', async () => {
+ vi.useFakeTimers();
+ try {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ error: { code: 429, message: 'Rate limited.' } }),
+ { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '7' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+
+ const promise = reviewWithGoogle(
+ { apiKey: 'test-key' },
+ 'gemma-4-31b-it',
+ { systemPrompt: 'system', userPrompt: 'user' },
+ );
+ promise.catch(() => {});
+
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ await vi.advanceTimersByTimeAsync(6_999);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(1);
+ const response = await promise;
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(response.rawText).toContain('"findings"');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('does not retry TypeErrors thrown after a successful Google response', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => {
+ throw new TypeError('parser exploded after response');
+ },
+ } as unknown as Response);
+
+ await expect(
+ reviewWithGoogle(
+ { apiKey: 'test-key' },
+ 'gemma-4-31b-it',
+ { systemPrompt: 'system', userPrompt: 'user' },
+ ),
+ ).rejects.toThrow('parser exploded after response');
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => {
let attempts = 0;
const env = createTestEnv({
@@ -207,6 +268,37 @@ describe('ModelService', () => {
expect(attempts).toBe(1);
});
+ it('aborts and fails fast (as a retryable timeout) when a Cloudflare model hangs past the timeout', async () => {
+ vi.useFakeTimers();
+ try {
+ let capturedSignal: AbortSignal | undefined;
+ const env = createTestEnv({
+ AI: {
+ run(_model: string, _request: any, options?: { signal?: AbortSignal }) {
+ capturedSignal = options?.signal;
+ // Model never responds -- only the timeout can end this call.
+ return new Promise(() => {});
+ },
+ } as any,
+ });
+
+ const promise = reviewWithCloudflare(env, '@cf/zai-org/glm-4.7-flash', {
+ systemPrompt: 'system',
+ userPrompt: 'user',
+ });
+ // Prevent an unhandled-rejection warning while the timer is still pending.
+ promise.catch(() => {});
+
+ await vi.advanceTimersByTimeAsync(45_000);
+
+ await expect(promise).rejects.toThrow('timed out after 45000ms');
+ // The underlying Workers-AI request was actually cancelled, not just abandoned.
+ expect(capturedSignal?.aborted).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it('tries the smaller Google fallback after the primary Google model fails', async () => {
let cloudflareCalls = 0;
const fetchMock = vi.spyOn(globalThis, 'fetch')
@@ -293,6 +385,100 @@ describe('ModelService', () => {
expect(response.modelUsed).toBe('gemma-4-26b-a4b-it');
});
+ it('still tries the primary model even when the shared job budget is already near the subrequest limit', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+ const tracker = new TokenTracker();
+ tracker.incrementSubrequests(40); // already at MAX_SUBREQUESTS (50) - SAFE_MARGIN (10)
+ const service = new ModelService(env, tracker);
+
+ const response = await service.reviewFile({
+ file: {
+ path: 'src/app.ts',
+ lineCount: 1,
+ hunks: [],
+ isDeleted: false,
+ isBinary: false,
+ isNew: false,
+ previousPath: null,
+ },
+ prTitle: 'Test',
+ prDescription: null,
+ config: {
+ ...defaultRepoConfig,
+ model: {
+ main: 'gemma-4-31b-it',
+ fallbacks: ['gemma-4-26b-a4b-it'],
+ size_overrides: [],
+ },
+ },
+ totalLineCount: 1,
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(response.modelUsed).toBe('gemma-4-31b-it');
+ });
+
+ it('skips remaining fallback models (instead of spending more of the shared budget) once near the subrequest limit', async () => {
+ // Google's client retries a 5xx once internally before giving up on a model, so the
+ // primary model alone can issue more than one raw fetch call; return a fresh Response for
+ // every call so retries do not reuse an already-consumed body.
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
+ new Response(
+ JSON.stringify({ error: { code: 500, message: 'Internal error encountered.', status: 'INTERNAL' } }),
+ { status: 500, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+ const tracker = new TokenTracker();
+ tracker.incrementSubrequests(40); // already at MAX_SUBREQUESTS (50) - SAFE_MARGIN (10)
+ const service = new ModelService(env, tracker);
+
+ await expect(
+ service.reviewFile({
+ file: {
+ path: 'src/app.ts',
+ lineCount: 1,
+ hunks: [],
+ isDeleted: false,
+ isBinary: false,
+ isNew: false,
+ previousPath: null,
+ },
+ prTitle: 'Test',
+ prDescription: null,
+ config: {
+ ...defaultRepoConfig,
+ model: {
+ main: 'gemma-4-31b-it',
+ fallbacks: ['gemma-4-26b-a4b-it'],
+ size_overrides: [],
+ },
+ },
+ totalLineCount: 1,
+ }),
+ ).rejects.toSatisfy(isRetryableModelError);
+
+ // Only the primary model was attempted (possibly with its own internal retry); the
+ // fallback model was skipped rather than risking tipping the shared invocation over
+ // Cloudflare's subrequest cap. The file is deferred for a later retry (via the
+ // RetryableModelError) instead of being burned through here.
+ expect(fetchMock.mock.calls.length).toBeGreaterThan(0);
+ for (const call of fetchMock.mock.calls) {
+ expect(String(call[0])).toContain('/models/gemma-4-31b-it:generateContent');
+ }
+ });
+
it('marks exhausted transient provider failures as retryable for the queue', async () => {
const env = createTestEnv({
AI: {
diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts
index 7af87de..de6f614 100644
--- a/test/review-flow.spec.ts
+++ b/test/review-flow.spec.ts
@@ -405,7 +405,7 @@ dbDescribe('Review Flow Lifecycle', () => {
phase: 'review',
});
- expect(result).toEqual({ action: 'next_phase', phase: 'review', delaySeconds: 60 });
+ expect(result).toEqual({ action: 'next_phase', phase: 'review', delaySeconds: 30 });
expect(reviewSpy).toHaveBeenCalled();
expect((env.REVIEW_QUEUE as any).sent).toHaveLength(0);
});
@@ -427,7 +427,6 @@ dbDescribe('Review Flow Lifecycle', () => {
generateMockDiff([
{ path: 'src/one.ts', content: 'console.log(1);' },
{ path: 'src/two.ts', content: 'console.log(2);' },
- { path: 'src/three.ts', content: 'console.log(3);' },
]),
);
let active = 0;
@@ -468,7 +467,7 @@ dbDescribe('Review Flow Lifecycle', () => {
baseRef: 'main',
configSnapshot: defaultRepoConfig,
});
- await updateJobFileCount(env, job.id, 3);
+ await updateJobFileCount(env, job.id, 2);
await updateJobStep(env, job.id, 'Preparation', { status: 'done' });
await runWithDb(env, async () => {
@@ -480,12 +479,12 @@ dbDescribe('Review Flow Lifecycle', () => {
});
expect(result).toEqual({ action: 'next_phase', phase: 'finalize', delaySeconds: 0 });
- expect(maxActive).toBe(3);
+ expect(maxActive).toBe(2);
expect((env.REVIEW_QUEUE as any).sent).toHaveLength(0);
});
const reviews = await getFileReviewsForJobs(env, [job.id]);
- expect(reviews.filter((review) => review.file_status === 'done')).toHaveLength(3);
+ expect(reviews.filter((review) => review.file_status === 'done')).toHaveLength(2);
reviewSpy.mockRestore();
getDiffSpy.mockRestore();
diff --git a/test/review-resilience.spec.ts b/test/review-resilience.spec.ts
new file mode 100644
index 0000000..37f5c7c
--- /dev/null
+++ b/test/review-resilience.spec.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { getDiffFiles, failJobAndCheckRun } from '@server/core/review';
+import { createTestEnv, generateMockDiff } from './helpers';
+import { defaultRepoConfig } from '@shared/schema';
+
+// Regression coverage for the subrequest-exhaustion incident (job bb9cf692...): a large PR's
+// review workflow re-fetched the PR diff from GitHub on every phase/chunk and, once the
+// Worker's subrequest budget was exhausted, the failure-reporting path silently swallowed
+// errors and could leave a job's DB failure state undone. These tests pin down the fixes for
+// both without requiring a real Postgres connection or live GitHub/Workflow infrastructure.
+
+const { failJobMock, getJobForProcessingMock, markJobCheckRunCompletedMock } = vi.hoisted(() => ({
+ failJobMock: vi.fn(),
+ getJobForProcessingMock: vi.fn(),
+ markJobCheckRunCompletedMock: vi.fn(),
+}));
+
+vi.mock('@server/db/jobs', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ failJob: failJobMock,
+ getJobForProcessing: getJobForProcessingMock,
+ markJobCheckRunCompleted: markJobCheckRunCompletedMock,
+ };
+});
+
+describe('getDiffFiles', () => {
+ const baseJob = { owner: 'test-owner', repo: 'test-repo', prNumber: 26 };
+
+ it('fetches the PR diff from GitHub once and reuses the cached copy on later calls for the same job', async () => {
+ const env = createTestEnv();
+ const job = { ...baseJob, id: `diff-cache-hit-${Date.now()}` };
+ const rawDiff = generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }]);
+ const github = { getPullRequestDiff: vi.fn().mockResolvedValue(rawDiff) };
+
+ const first = await getDiffFiles(env, job, github, defaultRepoConfig);
+ const second = await getDiffFiles(env, job, github, defaultRepoConfig);
+ const third = await getDiffFiles(env, job, github, defaultRepoConfig);
+
+ expect(github.getPullRequestDiff).toHaveBeenCalledTimes(1);
+ expect(first.map((f) => f.path)).toEqual(['src/app.ts']);
+ expect(second.map((f) => f.path)).toEqual(['src/app.ts']);
+ expect(third.map((f) => f.path)).toEqual(['src/app.ts']);
+ });
+
+ it('does not share cached diffs across different jobs', async () => {
+ const env = createTestEnv();
+ const jobA = { ...baseJob, id: `diff-cache-job-a-${Date.now()}` };
+ const jobB = { ...baseJob, id: `diff-cache-job-b-${Date.now()}` };
+ const githubA = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/one.ts', content: 'a' }])) };
+ const githubB = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/two.ts', content: 'b' }])) };
+
+ const filesA = await getDiffFiles(env, jobA, githubA, defaultRepoConfig);
+ const filesB = await getDiffFiles(env, jobB, githubB, defaultRepoConfig);
+
+ expect(githubA.getPullRequestDiff).toHaveBeenCalledTimes(1);
+ expect(githubB.getPullRequestDiff).toHaveBeenCalledTimes(1);
+ expect(filesA.map((f) => f.path)).toEqual(['src/one.ts']);
+ expect(filesB.map((f) => f.path)).toEqual(['src/two.ts']);
+ });
+
+ it('still returns the parsed files if caching the diff in KV fails', async () => {
+ const env = createTestEnv();
+ (env.APP_KV as any).put = vi.fn().mockRejectedValue(new Error('KV unavailable'));
+ const job = { ...baseJob, id: `diff-cache-put-fail-${Date.now()}` };
+ const github = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }])) };
+
+ const files = await getDiffFiles(env, job, github, defaultRepoConfig);
+
+ expect(files.map((f) => f.path)).toEqual(['src/app.ts']);
+ // The next phase would simply re-fetch from GitHub since the cache write failed; it must
+ // not throw and break the job.
+ });
+});
+
+describe('failJobAndCheckRun', () => {
+ const job = { id: 'job-fail-1', owner: 'test-owner', repo: 'test-repo', checkRunId: 42 };
+
+ beforeEach(() => {
+ failJobMock.mockReset();
+ getJobForProcessingMock.mockReset();
+ markJobCheckRunCompletedMock.mockReset();
+ });
+
+ it('durably records the DB failure even when the GitHub check-run update fails (e.g. subrequest limit exhausted)', async () => {
+ const env = createTestEnv();
+ failJobMock.mockResolvedValue(undefined);
+ getJobForProcessingMock.mockResolvedValue({ check_run_id: job.checkRunId });
+ const updateCheckRun = vi.fn().mockRejectedValue(new Error('Too many subrequests by single Worker invocation.'));
+
+ await expect(failJobAndCheckRun(env, job, { updateCheckRun }, 'boom')).resolves.toBeUndefined();
+
+ // Use expect.anything() rather than the literal env: env's APP_PRIVATE_KEY getter
+ // deliberately throws for unused test secrets, and toHaveBeenCalledWith's deep-equality
+ // check would otherwise trigger it while walking env's own properties.
+ expect(failJobMock).toHaveBeenCalledWith(expect.anything(), job.id, 'boom');
+ expect(updateCheckRun).toHaveBeenCalledTimes(1);
+ // Deliberately not marked complete: completeTerminalCheckRuns() picks this up and
+ // retries the GitHub update later, once a fresh invocation has its own subrequest budget.
+ expect(markJobCheckRunCompletedMock).not.toHaveBeenCalled();
+ });
+
+ it('does not attempt the GitHub call at all if the DB write itself fails', async () => {
+ const env = createTestEnv();
+ failJobMock.mockRejectedValue(new Error('Too many subrequests by single Worker invocation.'));
+ const updateCheckRun = vi.fn();
+
+ await expect(failJobAndCheckRun(env, job, { updateCheckRun }, 'boom')).resolves.toBeUndefined();
+
+ expect(failJobMock).toHaveBeenCalledWith(expect.anything(), job.id, 'boom');
+ expect(getJobForProcessingMock).not.toHaveBeenCalled();
+ expect(updateCheckRun).not.toHaveBeenCalled();
+ });
+
+ it('marks the check run completed once the GitHub update succeeds', async () => {
+ const env = createTestEnv();
+ failJobMock.mockResolvedValue(undefined);
+ getJobForProcessingMock.mockResolvedValue({ check_run_id: job.checkRunId });
+ const updateCheckRun = vi.fn().mockResolvedValue(undefined);
+
+ await failJobAndCheckRun(env, job, { updateCheckRun }, 'boom');
+
+ expect(updateCheckRun).toHaveBeenCalledWith(
+ job.owner,
+ job.repo,
+ job.checkRunId,
+ expect.objectContaining({ status: 'completed', conclusion: 'failure', summary: 'boom' }),
+ );
+ expect(markJobCheckRunCompletedMock).toHaveBeenCalledWith(expect.anything(), job.id);
+ });
+});
diff --git a/test/review-subrequest-completion.spec.ts b/test/review-subrequest-completion.spec.ts
new file mode 100644
index 0000000..9f59507
--- /dev/null
+++ b/test/review-subrequest-completion.spec.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { createTestEnv } from './helpers';
+
+// Regression coverage for the subrequest-exhaustion incident (PR #26 / job 9dda151e...): a
+// review phase that hit Cloudflare's per-invocation subrequest cap (Workers Free plan: 50)
+// terminally FAILED the whole job instead of resuming on a fresh budget, so large PRs could
+// never finish. These tests pin down that a per-invocation subrequest-limit error reschedules
+// the same phase (which the workflow re-runs in a fresh invocation with a fresh budget),
+// while an unrelated error still fails the job as before.
+
+const {
+ getJobForProcessingMock,
+ mapJobMock,
+ getOtherRunningJobsCountMock,
+ claimJobLeaseMock,
+ releaseJobLeaseMock,
+ markJobContinuationQueuedMock,
+ updateJobStepMock,
+ failJobMock,
+ markJobCheckRunCompletedMock,
+ getPullRequestMock,
+} = vi.hoisted(() => ({
+ getJobForProcessingMock: vi.fn(),
+ mapJobMock: vi.fn(),
+ getOtherRunningJobsCountMock: vi.fn(),
+ claimJobLeaseMock: vi.fn(),
+ releaseJobLeaseMock: vi.fn(),
+ markJobContinuationQueuedMock: vi.fn(),
+ updateJobStepMock: vi.fn(),
+ failJobMock: vi.fn(),
+ markJobCheckRunCompletedMock: vi.fn(),
+ getPullRequestMock: vi.fn(),
+}));
+
+vi.mock('@server/db/jobs', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getJobForProcessing: getJobForProcessingMock,
+ mapJob: mapJobMock,
+ getOtherRunningJobsCount: getOtherRunningJobsCountMock,
+ claimJobLease: claimJobLeaseMock,
+ releaseJobLease: releaseJobLeaseMock,
+ markJobContinuationQueued: markJobContinuationQueuedMock,
+ updateJobStep: updateJobStepMock,
+ failJob: failJobMock,
+ markJobCheckRunCompleted: markJobCheckRunCompletedMock,
+ };
+});
+
+vi.mock('@server/db/app-settings', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getReviewSettings: vi.fn().mockResolvedValue({ concurrencyLevel: 'low', maxComments: 20 }),
+ };
+});
+
+vi.mock('@server/services/github', () => ({
+ GitHubService: class {
+ getPullRequest = getPullRequestMock;
+ updateCheckRun = vi.fn().mockResolvedValue(undefined);
+ },
+}));
+
+// Imported after the mocks are registered.
+import { runReviewJob } from '@server/core/review';
+
+const JOB_ID = '9dda151e-0c61-4205-9cba-497027706698';
+
+const reviewJob = {
+ id: JOB_ID,
+ installationId: '123',
+ owner: 'test-owner',
+ repo: 'test-repo',
+ prNumber: 26,
+ checkRunId: null as number | null,
+ retryOfJobId: null as string | null,
+ trigger: 'auto' as const,
+ createdAt: new Date().toISOString(),
+ // Preparation already done so runReviewPhase proceeds to the review work (and thus to the
+ // getPullRequest call we make throw) instead of falling back into runPreparePhase.
+ steps: [{ name: 'Preparation', status: 'done' }],
+ configSnapshot: undefined,
+};
+
+describe('runReviewJob subrequest-budget handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // A claimable, non-terminal job every time.
+ getJobForProcessingMock.mockResolvedValue({ check_run_id: null });
+ mapJobMock.mockReturnValue(reviewJob);
+ getOtherRunningJobsCountMock.mockResolvedValue(0);
+ claimJobLeaseMock.mockResolvedValue({ status: 'claimed', row: {} });
+ releaseJobLeaseMock.mockResolvedValue(undefined);
+ markJobContinuationQueuedMock.mockResolvedValue(undefined);
+ updateJobStepMock.mockResolvedValue(undefined);
+ failJobMock.mockResolvedValue(undefined);
+ });
+
+ it('reschedules the same phase (fresh budget) instead of failing the job when it hits the per-invocation subrequest limit', async () => {
+ const env = createTestEnv();
+ getPullRequestMock.mockRejectedValue(new Error('Too many subrequests by single Worker invocation.'));
+
+ const result = await runReviewJob(env, { jobId: JOB_ID, phase: 'review' } as any);
+
+ expect(result).toEqual({ action: 'next_phase', phase: 'review', delaySeconds: expect.any(Number) });
+ // Queued for continuation and the lease released so the fresh invocation can re-claim it...
+ expect(markJobContinuationQueuedMock).toHaveBeenCalledTimes(1);
+ expect(releaseJobLeaseMock).toHaveBeenCalledTimes(1);
+ // ...and crucially the job was NOT marked failed.
+ expect(failJobMock).not.toHaveBeenCalled();
+ });
+
+ it('still fails the job terminally for an unrelated (non-subrequest, non-retryable) error', async () => {
+ const env = createTestEnv();
+ getPullRequestMock.mockRejectedValue(new Error('totally unexpected boom'));
+
+ const result = await runReviewJob(env, { jobId: JOB_ID, phase: 'review' } as any);
+
+ expect(result).toEqual({ action: 'ack' });
+ expect(failJobMock).toHaveBeenCalledWith(expect.anything(), JOB_ID, 'totally unexpected boom');
+ expect(markJobContinuationQueuedMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps rescheduling while the continuation count is still under the ceiling', async () => {
+ const env = createTestEnv();
+ // markJobContinuationQueued returns the post-increment count; a value at/under the ceiling
+ // (MAX_JOB_CONTINUATIONS = 20) must still reschedule rather than give up.
+ markJobContinuationQueuedMock.mockResolvedValue(20);
+ getPullRequestMock.mockRejectedValue(new Error('Too many subrequests by single Worker invocation.'));
+
+ const result = await runReviewJob(env, { jobId: JOB_ID, phase: 'review' } as any);
+
+ expect(result).toEqual({ action: 'next_phase', phase: 'review', delaySeconds: expect.any(Number) });
+ expect(failJobMock).not.toHaveBeenCalled();
+ });
+
+ it('fails the job terminally once it exceeds the continuation ceiling without making progress', async () => {
+ const env = createTestEnv();
+ // Post-increment count above MAX_JOB_CONTINUATIONS (20): the job is wedged, so it must stop
+ // churning and fail terminally instead of rescheduling yet again.
+ markJobContinuationQueuedMock.mockResolvedValue(21);
+ getPullRequestMock.mockRejectedValue(new Error('Too many subrequests by single Worker invocation.'));
+
+ const result = await runReviewJob(env, { jobId: JOB_ID, phase: 'review' } as any);
+
+ expect(result).toEqual({ action: 'ack' });
+ expect(failJobMock).toHaveBeenCalledTimes(1);
+ expect(failJobMock.mock.calls[0][2]).toMatch(/could not make progress after 21 continuation attempts/);
+ expect(releaseJobLeaseMock).toHaveBeenCalled();
+ });
+});
diff --git a/test/setup.ts b/test/setup.ts
index e7a505b..eda9c3b 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -103,6 +103,14 @@ if (typeof window !== 'undefined' && !window.matchMedia) {
});
}
+if (typeof window !== 'undefined' && !window.ResizeObserver) {
+ window.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+}
+
const originalConsoleWarn = console.warn;
console.warn = (...args: any[]) => {
if (typeof args[0] === 'string' && args[0].includes('The width(-1) and height(-1) of chart should be greater than 0')) {
diff --git a/test/token-tracker.spec.ts b/test/token-tracker.spec.ts
new file mode 100644
index 0000000..f83435b
--- /dev/null
+++ b/test/token-tracker.spec.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect } from 'vitest';
+import { TokenTracker } from '@server/core/token-tracker';
+
+// Regression coverage for the subrequest-exhaustion incident (job bb9cf692...): the review
+// workflow could burn through Cloudflare's per-invocation subrequest cap (Workers Free plan:
+// 50 subrequests/invocation) without ever checking how much budget was left. TokenTracker
+// already tracked a running count but exposed no way to ask "how much is safely left", so
+// nothing consulted it before starting more concurrent work. These tests pin down the new
+// remainingSafeBudget() accessor that review.ts and model.ts now use to throttle themselves.
+
+describe('TokenTracker.remainingSafeBudget', () => {
+ it('starts with the full margin below the hard cap available', () => {
+ const tracker = new TokenTracker();
+ // MAX_SUBREQUESTS (50) - SAFE_MARGIN (10) = 40, with nothing spent yet.
+ expect(tracker.remainingSafeBudget()).toBe(40);
+ });
+
+ it('shrinks by exactly what has been spent so far', () => {
+ const tracker = new TokenTracker();
+ tracker.incrementSubrequests(10);
+ expect(tracker.remainingSafeBudget()).toBe(30);
+
+ tracker.incrementSubrequests(5);
+ expect(tracker.remainingSafeBudget()).toBe(25);
+ });
+
+ it('never goes negative once spending exceeds the safe margin', () => {
+ const tracker = new TokenTracker();
+ tracker.incrementSubrequests(45);
+ expect(tracker.remainingSafeBudget()).toBe(0);
+
+ tracker.incrementSubrequests(100);
+ expect(tracker.remainingSafeBudget()).toBe(0);
+ });
+
+ it('agrees with isNearLimit at the same threshold', () => {
+ const tracker = new TokenTracker();
+ tracker.incrementSubrequests(39);
+ expect(tracker.isNearLimit()).toBe(false);
+ expect(tracker.remainingSafeBudget()).toBeGreaterThan(0);
+
+ tracker.incrementSubrequests(1);
+ expect(tracker.isNearLimit()).toBe(true);
+ expect(tracker.remainingSafeBudget()).toBe(0);
+ });
+});
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 77def02..1de962c 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -66,6 +66,11 @@
"class_name": "ReviewWorkflow"
}
],
+ "triggers": {
+ "crons": [
+ "*/2 * * * *"
+ ]
+ },
"assets": {
"directory": "./dist/client",
"binding": "ASSETS",