From 42186fb1a6b5d9924a78b76459fd250a97e1fad0 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 15 May 2026 21:24:57 +0530 Subject: [PATCH 01/32] add: gh actions workflow tests --- .dev.vars.example | 63 +++++++++------------------- .env.test.example | 19 +++++++++ .github/workflows/ci.yml | 31 ++++++++++++++ .gitignore | 1 + CONTRIBUTING.md | 6 ++- package.json | 2 +- scripts/test.mjs | 77 +++++++++++++++++++++++++++++++++++ test/api.spec.ts | 20 ++++----- test/helpers.ts | 63 ++++++++++++---------------- test/review-flow.spec.ts | 6 +-- test/setup.ts | 56 +++++++++++++++++-------- test/webhook-handling.spec.ts | 10 ++--- 12 files changed, 235 insertions(+), 119 deletions(-) create mode 100644 .env.test.example create mode 100644 scripts/test.mjs diff --git a/.dev.vars.example b/.dev.vars.example index f6ad225..1e05d90 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,52 +1,29 @@ -# ────────────────────────────────────────────────────────────────────────────── -# Codra Environment Configuration Example -# Copy this file to .dev.vars for local development: cp .dev.vars.example .dev.vars -# ────────────────────────────────────────────────────────────────────────────── +# Codra local development environment example +# Copy this file to .dev.vars for local development. +# Keep real secrets only in .dev.vars or your deployment secret store. -# --- GitHub App Authentication --- -# Create at: https://github.com/settings/apps -APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" -GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID" -GITHUB_APP_SLUG="REPLACE_WITH_YOUR_APP_SLUG" -GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET" - -# --- Dashboard OAuth (GitHub) --- -# Use the same GitHub App's Client ID/Secret or a separate OAuth App -GITHUB_CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID" -GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" -AUTH_CALLBACK_URL="http://localhost:8787/auth/github/callback" - -# --- Authorization --- -# Comma-separated list of GitHub usernames allowed to access the dashboard -DASHBOARD_ALLOWED_USERS="username1,username2" +# --- Integration tests --- +TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra" -# --- AI Intelligence (Gemini) --- -# Generate at: https://aistudio.google.com/app/apikey +# --- AI provider --- GEMINI_API_KEY="REPLACE_WITH_YOUR_GEMINI_API_KEY" -# --- Database Connections --- - -# 1. Local Development (Used by 'wrangler dev' for the HYPERDRIVE binding) -# This usually points to a local Postgres instance or a dev branch in Neon. -CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://user:password@localhost:5432/codra_dev" - -# 2. Migrations (Used by 'npm run migrate') -# This script runs via Node.js and needs a direct connection to the DB you want to migrate. -DATABASE_URL="postgresql://user:password@localhost:5432/codra_dev" - -# 3. Integration Tests (Used by 'npm run test') -# MUST be a separate database to avoid data loss during test sweeps. -TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra_test" +# --- GitHub App and OAuth --- +GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET" +GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID" +GITHUB_CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID" +GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" +APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" -# --- Cloudflare DLQ / Queue Management (Required) --- -# Required for DLQ inspection, replay, and purge via /api/dlq -# Create or identify the DLQ queue, then set CF_DLQ_ID to that queue's ID. -# Generate token at https://dash.cloudflare.com/profile/api-tokens (Queues:Edit permission) -CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN" +# --- Cloudflare API --- CF_ACCOUNT_ID="REPLACE_WITH_YOUR_CLOUDFLARE_ACCOUNT_ID" -CF_DLQ_ID="REPLACE_WITH_YOUR_DLQ_QUEUE_ID" +CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN" -# --- Application Settings --- +# --- Application URLs and mode --- APP_URL="http://localhost:8787" -BOT_USERNAME="codra-app-dev" +AUTH_CALLBACK_URL="http://localhost:8787/auth/github/callback" ENVIRONMENT="development" + +# --- Database connections --- +DATABASE_URL="postgresql://user:password@localhost:5432/codra_dev" +CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://user:password@localhost:5432/codra_dev" diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..31e1b2e --- /dev/null +++ b/.env.test.example @@ -0,0 +1,19 @@ +# Codra test environment example. +# Copy to .env.test for local tests. These values are fake and must not be +# reused for production, staging, or any real external service. + +GITHUB_APP_SLUG="codra-test-app" +GITHUB_APP_WEBHOOK_SECRET="fake-webhook-secret" + +GITHUB_CLIENT_ID="fake-dashboard-client-id" +GITHUB_CLIENT_SECRET="fake-dashboard-client-secret" +AUTH_CALLBACK_URL="https://codra.test/auth/github/callback" +DASHBOARD_ALLOWED_USERS="devarshishimpi" + +APP_URL="https://codra.test" +BOT_USERNAME="codra-test-app" + +# Required. Must point at a disposable Postgres database because tests reset and +# write data while running. +DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/codra_test" +TEST_DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/codra_test" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 632bf80..526bc02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,16 @@ name: Code Quality on: + workflow_dispatch: push: branches: - main pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review branches: - main @@ -16,6 +22,31 @@ jobs: verify: name: Verify Stability runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: codra_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/codra_test + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/codra_test + GITHUB_APP_SLUG: codra-test-app + GITHUB_APP_WEBHOOK_SECRET: fake-webhook-secret + GITHUB_CLIENT_ID: fake-dashboard-client-id + GITHUB_CLIENT_SECRET: fake-dashboard-client-secret + AUTH_CALLBACK_URL: https://codra.test/auth/github/callback + APP_URL: https://codra.test + DASHBOARD_ALLOWED_USERS: devarshishimpi + BOT_USERNAME: codra-test-app steps: - name: Checkout repository diff --git a/.gitignore b/.gitignore index 9c61da5..e6c2e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ web_modules/ .env .env.* !.env.example +!.env.test.example # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 564345b..6a0d1c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,10 +52,12 @@ npm run dev ## 🧪 Testing -We use **Vitest** for unit and integration testing. `npm test` runs the non-database tests by default and automatically enables DB integration tests when `TEST_DATABASE_URL` points at a disposable Postgres database. +We use **Vitest** for unit and integration testing. `npm test` requires a disposable Postgres database, runs migrations against it, and then runs the full test suite. + +The test runner loads `.env.test`, `.env.local`, `.env`, `.dev.vars`, and then `.env.test.example`. Override `TEST_DATABASE_URL` in one of the private env files when your local test database does not match the example URL. ```bash -# Run all tests +# Run the full test suite npm test # Run tests in watch mode diff --git a/package.json b/package.json index d8db3c7..0f41312 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dev:worker": "wrangler dev --local", "start": "npm run dev", "migrate": "node scripts/migrate.mjs", - "test": "vitest run", + "test": "node scripts/test.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..0eeed33 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const envFiles = ['.env.test', '.env.local', '.env', '.dev.vars', '.env.test.example']; + +function parseEnvValue(value) { + let trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + trimmed = trimmed.slice(1, -1); + } + + return trimmed.replace(/\\n/g, '\n'); +} + +function usableEnvValue(value) { + return value && value !== 'undefined' && value !== 'null' ? value : null; +} + +function loadEnvFiles() { + for (const file of envFiles) { + try { + const content = readFileSync(path.join(rootDir, file), 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) continue; + + const key = trimmed.slice(0, separatorIndex).trim(); + if (process.env[key] === undefined) { + process.env[key] = parseEnvValue(trimmed.slice(separatorIndex + 1)); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + } + } +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: rootDir, + env: process.env, + stdio: 'inherit', + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +loadEnvFiles(); + +if (!usableEnvValue(process.env.TEST_DATABASE_URL)) { + console.error([ + 'TEST_DATABASE_URL is required to run the full test suite.', + 'Copy .env.test.example to .env.test and point TEST_DATABASE_URL at a disposable Postgres database.', + ].join('\n')); + process.exit(1); +} + +process.env.DATABASE_URL = usableEnvValue(process.env.DATABASE_URL) ?? process.env.TEST_DATABASE_URL; + +run(process.execPath, ['scripts/migrate.mjs']); +run(process.execPath, ['node_modules/vitest/vitest.mjs', 'run']); diff --git a/test/api.spec.ts b/test/api.spec.ts index 8d9ed3c..1b0ccd5 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -14,11 +14,9 @@ import type { StatsResponse, UpdatesEmailResponse, } from '@shared/api'; -import { createTestEnv, hasConfiguredTestDatabaseUrl } from './helpers'; +import { createTestEnv } from './helpers'; import { vi } from 'vitest'; -const dbIt = hasConfiguredTestDatabaseUrl() ? it : it.skip; - function mockGitHubProfile(login = 'devarshishimpi') { return { id: 42, @@ -117,7 +115,7 @@ describe('Dashboard API Suite', () => { expect(response.headers.get('location')).toBe('/login?error=not_allowed'); }); - dbIt('allows access to /api/jobs with a valid GitHub session', async () => { + it('allows access to /api/jobs with a valid GitHub session', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); const response = await app.request('/api/jobs', { @@ -264,7 +262,7 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(404); }); - dbIt('fetches job details accurately', async () => { + it('fetches job details accurately', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); @@ -295,7 +293,7 @@ describe('Dashboard API Suite', () => { expect(data.job.files).toBeDefined(); }); - dbIt('fetches job details when stored comments have null code suggestions', async () => { + it('fetches job details when stored comments have null code suggestions', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); @@ -349,7 +347,7 @@ describe('Dashboard API Suite', () => { expect(data.job.files[0].parsedComments[0].codeSuggestion).toBeNull(); }); - dbIt('returns stats successfully', async () => { + it('returns stats successfully', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); @@ -428,7 +426,7 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(400); }); - dbIt('returns repository list', async () => { + it('returns repository list', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); @@ -453,7 +451,7 @@ describe('Dashboard API Suite', () => { expect(response.headers.get('location')).toBe('https://github.com/apps/my-codra-install/installations/new'); }); - dbIt('rejects invalid repository config patches', async () => { + it('rejects invalid repository config patches', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); const repo = `invalid-config-${Date.now()}`; @@ -481,7 +479,7 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(400); }); - dbIt('rejects string booleans in repository config patches', async () => { + it('rejects string booleans in repository config patches', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); const repo = `invalid-enabled-${Date.now()}`; @@ -530,7 +528,7 @@ describe('Dashboard API Suite', () => { expect(requestedUrl).toBe('https://api.github.com/repos/owner/repo/contents/src/path%20with%20spaces/app.ts'); }); - dbIt('keeps repo model settings inherited when loading global strategy', async () => { + it('keeps repo model settings inherited when loading global strategy', async () => { const env = createTestEnv(); const repo = `global-inherit-${Date.now()}`; diff --git a/test/helpers.ts b/test/helpers.ts index a698918..251b8b7 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -54,33 +54,24 @@ export class MockQueue { } } -// A valid PKCS#8 dummy private key (2048-bit RSA) -export const DUMMY_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCuG/W/29qB8S3q -/U4+4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ -5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v -8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/ -W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ -5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v -8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/W9J/4M1v8XJ/U0zZ5y8/Y+Y/ -W9J/AgMBAAECggEAIl77HjE= ------END PRIVATE KEY-----`; - -export const TEST_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/codra_test'; - function usableEnvValue(value: string | undefined) { return value && value !== 'undefined' && value !== 'null' ? value : null; } -export function getTestDatabaseUrl() { - return ( - usableEnvValue(process.env.TEST_DATABASE_URL) ?? - TEST_DATABASE_URL - ); +function requiredEnv(key: keyof NodeJS.ProcessEnv) { + const value = usableEnvValue(process.env[key]); + if (!value) { + throw new Error(`Missing required test environment variable: ${key}`); + } + return value; } -export function hasConfiguredTestDatabaseUrl() { - return Boolean(usableEnvValue(process.env.TEST_DATABASE_URL)); +function unusedEnv(key: string): string { + throw new Error(`${key} is not required by the current test suite. Add it to the test env only when a test exercises that path.`); +} + +export function getTestDatabaseUrl() { + return requiredEnv('TEST_DATABASE_URL'); } export function createTestEnv(overrides: Partial = {}): AppBindings { @@ -96,21 +87,21 @@ export function createTestEnv(overrides: Partial = {}): AppBindings HYPERDRIVE: { connectionString: getTestDatabaseUrl(), }, - APP_PRIVATE_KEY: DUMMY_PRIVATE_KEY, - GITHUB_APP_ID: '123', - GITHUB_APP_SLUG: 'codra-app', - GITHUB_APP_WEBHOOK_SECRET: 'topsecret', - GITHUB_CLIENT_ID: 'dashboard-client-id', - GITHUB_CLIENT_SECRET: 'dashboard-client-secret', - AUTH_CALLBACK_URL: 'https://codra.test/auth/github/callback', - APP_URL: 'https://codra.test', - DASHBOARD_ALLOWED_USERS: 'devarshishimpi', - GEMINI_API_KEY: 'gemini-key', - BOT_USERNAME: 'codra-app', - ENVIRONMENT: 'test', - CF_API_TOKEN: 'cf-api-token', - CF_ACCOUNT_ID: 'cf-account-id', - CF_DLQ_ID: 'cf-dlq-id', + get APP_PRIVATE_KEY() { return unusedEnv('APP_PRIVATE_KEY'); }, + get GITHUB_APP_ID() { return unusedEnv('GITHUB_APP_ID'); }, + GITHUB_APP_SLUG: requiredEnv('GITHUB_APP_SLUG'), + GITHUB_APP_WEBHOOK_SECRET: requiredEnv('GITHUB_APP_WEBHOOK_SECRET'), + GITHUB_CLIENT_ID: requiredEnv('GITHUB_CLIENT_ID'), + GITHUB_CLIENT_SECRET: requiredEnv('GITHUB_CLIENT_SECRET'), + AUTH_CALLBACK_URL: requiredEnv('AUTH_CALLBACK_URL'), + APP_URL: requiredEnv('APP_URL'), + DASHBOARD_ALLOWED_USERS: requiredEnv('DASHBOARD_ALLOWED_USERS'), + get GEMINI_API_KEY() { return unusedEnv('GEMINI_API_KEY'); }, + BOT_USERNAME: requiredEnv('BOT_USERNAME'), + get ENVIRONMENT() { return unusedEnv('ENVIRONMENT'); }, + get CF_API_TOKEN() { return unusedEnv('CF_API_TOKEN'); }, + get CF_ACCOUNT_ID() { return unusedEnv('CF_ACCOUNT_ID'); }, + get CF_DLQ_ID() { return unusedEnv('CF_DLQ_ID'); }, ...overrides, }; } diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index 76e5579..e022d3b 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -1,5 +1,5 @@ import { runReviewJob } from '@server/core/review'; -import { createTestEnv, generateMockDiff, hasConfiguredTestDatabaseUrl } from './helpers'; +import { createTestEnv, generateMockDiff } from './helpers'; import { vi } from 'vitest'; import { findExistingJobForHead, getJobForProcessing, insertJob } from '@server/db/jobs'; import { defaultRepoConfig } from '@shared/schema'; @@ -68,9 +68,7 @@ vi.mock('@server/services/model', () => { return { ModelService: MockModelService }; }); -const dbDescribe = hasConfiguredTestDatabaseUrl() ? describe : describe.skip; - -dbDescribe('Review Flow Lifecycle', () => { +describe('Review Flow Lifecycle', () => { const env = createTestEnv(); it('completes a full review from pending job to finished', async () => { diff --git a/test/setup.ts b/test/setup.ts index 89ec1f4..372ce52 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,6 +2,19 @@ import { vi } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +const TEST_ENV_FILES = ['.env.test', '.env.local', '.env', '.dev.vars', '.env.test.example']; +const REQUIRED_TEST_ENV_KEYS = [ + 'GITHUB_APP_SLUG', + 'GITHUB_APP_WEBHOOK_SECRET', + 'GITHUB_CLIENT_ID', + 'GITHUB_CLIENT_SECRET', + 'AUTH_CALLBACK_URL', + 'APP_URL', + 'DASHBOARD_ALLOWED_USERS', + 'BOT_USERNAME', + 'TEST_DATABASE_URL', +]; + // Global mocks for Cloudflare environment vi.stubGlobal('QUEUE', { send: async (msg: any) => { @@ -10,19 +23,25 @@ vi.stubGlobal('QUEUE', { }); function parseEnvValue(value: string) { - const trimmed = value.trim(); + let trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { - return trimmed.slice(1, -1); + trimmed = trimmed.slice(1, -1); } - return trimmed; + return trimmed.replace(/\\n/g, '\n'); +} + +function usableEnvValue(value: string | undefined) { + return value && value !== 'undefined' && value !== 'null' ? value : null; } -function readTestDatabaseUrlFromEnvFiles() { - for (const file of ['.env.test', '.env.local', '.env', '.dev.vars']) { +function loadTestEnvFromFiles() { + const keys = new Set(REQUIRED_TEST_ENV_KEYS); + + for (const file of TEST_ENV_FILES) { try { const content = readFileSync(path.join(process.cwd(), file), 'utf8'); for (const line of content.split(/\r?\n/)) { @@ -33,8 +52,8 @@ function readTestDatabaseUrlFromEnvFiles() { if (separatorIndex === -1) continue; const key = trimmed.slice(0, separatorIndex).trim(); - if (key === 'TEST_DATABASE_URL') { - return parseEnvValue(trimmed.slice(separatorIndex + 1)); + if (keys.has(key) && process.env[key] === undefined) { + process.env[key] = parseEnvValue(trimmed.slice(separatorIndex + 1)); } } } catch (error) { @@ -43,19 +62,24 @@ function readTestDatabaseUrlFromEnvFiles() { } } } - - return null; } -// Postgres database URL for integration tests. Set TEST_DATABASE_URL or put -// TEST_DATABASE_URL in .env.test/.env.local/.env/.dev.vars. -const configuredDatabaseUrl = process.env.TEST_DATABASE_URL || readTestDatabaseUrlFromEnvFiles(); -if (configuredDatabaseUrl && configuredDatabaseUrl !== 'undefined' && configuredDatabaseUrl !== 'null') { - process.env.TEST_DATABASE_URL = configuredDatabaseUrl; +function assertRequiredTestEnv() { + const missing = REQUIRED_TEST_ENV_KEYS.filter((key) => !usableEnvValue(process.env[key])); + if (missing.length === 0) return; + + throw new Error([ + `Missing required test environment variables: ${missing.join(', ')}.`, + 'Set these values in .env.test, .env.local, .env, .dev.vars, .env.test.example, or CI.', + 'TEST_DATABASE_URL must point to a disposable Postgres database so the full test suite can run.', + ].join('\n')); } -// Standard test timeout -vi.setConfig({ testTimeout: 20000 }); +loadTestEnvFromFiles(); +assertRequiredTestEnv(); + +// Database-backed review flow tests can be slow on local Postgres and CI. +vi.setConfig({ testTimeout: 300000 }); if (typeof window !== 'undefined' && !window.matchMedia) { Object.defineProperty(window, 'matchMedia', { diff --git a/test/webhook-handling.spec.ts b/test/webhook-handling.spec.ts index cc425c8..86c86c9 100644 --- a/test/webhook-handling.spec.ts +++ b/test/webhook-handling.spec.ts @@ -1,9 +1,7 @@ import { createApp } from '@server/app'; -import { createMockPRWebhook, createTestEnv, hasConfiguredTestDatabaseUrl } from './helpers'; +import { createMockPRWebhook, createTestEnv } from './helpers'; import { vi } from 'vitest'; -const dbIt = hasConfiguredTestDatabaseUrl() ? it : it.skip; - // Mock GitHubClient to avoid real JWT signing and network calls vi.mock('@server/core/github', async (importOriginal) => { const actual = await importOriginal() as any; @@ -80,7 +78,7 @@ describe('Webhook Handling Suite', () => { expect(response.status).toBe(400); }); - dbIt('accepts valid pull_request.opened and queues a job', async () => { + it('accepts valid pull_request.opened and queues a job', async () => { const repoName = `repo-${Date.now()}`; const rawPayload = createMockPRWebhook({ action: 'opened', @@ -120,7 +118,7 @@ describe('Webhook Handling Suite', () => { expect(queue.sent[0].payload).toBeUndefined(); }); - dbIt('acknowledges unsupported GitHub events without queueing review work', async () => { + it('acknowledges unsupported GitHub events without queueing review work', async () => { const rawPayload = createMockPRWebhook({ action: 'opened', repository: { name: `repo-${Date.now()}-check-suite`, owner: { login: 'test-owner' } }, @@ -153,7 +151,7 @@ describe('Webhook Handling Suite', () => { expect(queue.sent).toHaveLength(0); }); - dbIt('ignores webhooks for draft PRs', async () => { + it('ignores webhooks for draft PRs', async () => { const draftPayload = createMockPRWebhook({ action: 'opened', pull_request: { draft: true, number: 99, head: { sha: 'abc' }, base: { sha: 'def' }, user: { login: 'a' } } From cf25453d5ff4fda96a7932bc1547f6a51cc840d8 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 19 May 2026 05:51:07 +0530 Subject: [PATCH 02/32] feat: add resumable review queue processing --- db/migrations/002_resumable_queue_jobs.sql | 29 + src/server/core/job-recovery.ts | 53 ++ src/server/core/model-output.ts | 13 +- src/server/core/review.ts | 940 ++++++++++++--------- src/server/db/client.ts | 4 +- src/server/db/file-reviews.ts | 107 +++ src/server/db/jobs.ts | 255 +++++- src/server/env.ts | 2 +- src/server/index.ts | 67 +- src/server/models/cloudflare.ts | 56 +- src/server/models/google.ts | 7 +- src/server/routes/api/jobs.ts | 6 + src/server/routes/webhook.ts | 1 + src/server/services/model.ts | 51 ++ src/shared/schema.ts | 1 + test/helpers.ts | 4 +- test/model-service.spec.ts | 104 ++- test/resumable-queue.spec.ts | 204 +++++ test/review-flow.spec.ts | 35 +- test/webhook-handling.spec.ts | 1 + 20 files changed, 1453 insertions(+), 487 deletions(-) create mode 100644 db/migrations/002_resumable_queue_jobs.sql create mode 100644 src/server/core/job-recovery.ts create mode 100644 test/resumable-queue.spec.ts diff --git a/db/migrations/002_resumable_queue_jobs.sql b/db/migrations/002_resumable_queue_jobs.sql new file mode 100644 index 0000000..c9afeb3 --- /dev/null +++ b/db/migrations/002_resumable_queue_jobs.sql @@ -0,0 +1,29 @@ +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx + ON jobs (lease_expires_at) + WHERE status = 'running' AND lease_expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx + ON jobs (status, check_run_completed_at) + WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; + +CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx + ON jobs (last_queue_message_at, heartbeat_at) + WHERE status = 'running' AND lease_expires_at IS NULL; + +DELETE FROM file_reviews fr +USING ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number + FROM file_reviews +) ranked +WHERE fr.id = ranked.id + AND ranked.row_number > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key + ON file_reviews (job_id, file_path); diff --git a/src/server/core/job-recovery.ts b/src/server/core/job-recovery.ts new file mode 100644 index 0000000..92e0ae7 --- /dev/null +++ b/src/server/core/job-recovery.ts @@ -0,0 +1,53 @@ +import type { AppBindings } from '@server/env'; +import { getTerminalJobsNeedingCheckRunCompletion, markJobCheckRunCompleted, recoverExpiredJobLeases } from '@server/db/jobs'; +import { logger } from '@server/core/logger'; +import { GitHubService } from '@server/services/github'; + +const MAX_RECOVERY_COUNT = 3; + +export async function recoverJobs(env: AppBindings) { + try { + const recovered = await recoverExpiredJobLeases(env, MAX_RECOVERY_COUNT); + for (const jobId of recovered.requeuedJobIds) { + await env.REVIEW_QUEUE.send({ + jobId, + deliveryId: crypto.randomUUID(), + phase: 'review', + }); + } + + if (recovered.requeuedJobIds.length > 0 || recovered.failedJobs.length > 0) { + logger.warn('Expired job leases recovered', { + requeued: recovered.requeuedJobIds.length, + failed: recovered.failedJobs.length, + }); + } + } catch (err) { + logger.error('Failed to recover expired job leases', err instanceof Error ? err : new Error(String(err))); + } +} + +export async function completeTerminalCheckRuns(env: AppBindings) { + const jobs = await getTerminalJobsNeedingCheckRunCompletion(env); + for (const job of jobs) { + if (!job.check_run_id) continue; + + try { + const github = new GitHubService(env, job.installation_id); + await github.updateCheckRun(job.owner, job.repo, job.check_run_id, { + status: 'completed', + conclusion: job.status === 'superseded' ? 'neutral' : 'failure', + title: job.status === 'superseded' ? 'Review superseded' : 'Review failed', + summary: job.error_msg ?? (job.status === 'superseded' ? 'Superseded by a newer commit or job.' : 'Review failed.'), + }); + await markJobCheckRunCompleted(env, job.id); + } catch (error) { + logger.error(`Failed to complete terminal check run for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + } +} + +export async function runOpportunisticJobMaintenance(env: AppBindings) { + await recoverJobs(env); + await completeTerminalCheckRuns(env); +} diff --git a/src/server/core/model-output.ts b/src/server/core/model-output.ts index fc8ebc9..856912e 100644 --- a/src/server/core/model-output.ts +++ b/src/server/core/model-output.ts @@ -5,6 +5,13 @@ import { findClosestValidLine, findPositionForLine, getValidNewLines, getValidPo import type { FileDiff } from './diff'; import { jsonrepair } from 'jsonrepair'; +const MAX_LOGGED_MODEL_OUTPUT_CHARS = 2_000; + +function truncateForLog(value: string) { + if (value.length <= MAX_LOGGED_MODEL_OUTPUT_CHARS) return value; + return `${value.slice(0, MAX_LOGGED_MODEL_OUTPUT_CHARS)}... [truncated ${value.length - MAX_LOGGED_MODEL_OUTPUT_CHARS} chars]`; +} + function hasReviewKeys(input: string) { return /"(findings|overall_explanation|overall_correctness|overall_confidence_score|summary)"\s*:/.test(input); } @@ -253,7 +260,7 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): { throw new Error('Model response did not contain review JSON keys.'); } } catch (e) { - logger.error('Failed to extract JSON from model response', { raw, error: e }); + logger.error('Failed to extract JSON from model response', { raw: truncateForLog(raw), error: e }); throw new Error('Could not find JSON root in model response.'); } @@ -269,14 +276,14 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): { try { repaired = jsonrepair(preprocessed); } catch (e) { - logger.warn('jsonrepair failed to fix model output, using preprocessed text', { preprocessed, error: e }); + logger.warn('jsonrepair failed to fix model output, using preprocessed text', { preprocessed: truncateForLog(preprocessed), error: e }); } let parsedJson: any; try { parsedJson = JSON.parse(repaired); } catch (e) { - logger.error('Critical JSON parse error after extraction and repair', { repaired, error: e }); + logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateForLog(repaired), error: e }); throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`); } diff --git a/src/server/core/review.ts b/src/server/core/review.ts index de56f0b..3a5db1e 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -2,13 +2,13 @@ import { logger } from './logger'; import { isSupportedGitHubWebhookEvent, type GitHubWebhookEventName, type GitHubWebhookPayload, type IssueCommentWebhookPayload, type PullRequestWebhookPayload } from '@shared/github'; import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema'; import type { AppBindings } from '@server/env'; -import { getFileReviewsForJobs } from '@server/db/file-reviews'; -import { completeJob, failJob, findExistingJobForHead, getJobForProcessing, insertJob, mapJob, startJobProcessing, completePreparationStep, supersedeOlderJobs, updateJobCheckRun, updateJobStep } from '@server/db/jobs'; +import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; +import { claimJobLease, completeJob, completePreparationStep, failJob, findExistingJobForHead, getJobForProcessing, heartbeatJobLease, insertJob, mapJob, markJobCheckRunCompleted, markJobContinuationQueued, releaseJobLease, supersedeOlderJobs, updateJobCheckRun, updateJobStep } from '@server/db/jobs'; import { filterReviewableFiles, parseUnifiedDiff } from './diff'; import { GitHubService } from '../services/github'; import { GitHubClient } from './github'; -import { ModelService } from '../services/model'; +import { isRetryableModelError, ModelService } from '../services/model'; import { FormatterService } from '../services/formatter'; import { TokenTracker } from './token-tracker'; import { loadRepoConfig } from './config'; @@ -16,6 +16,41 @@ import { getWebhookDelivery } from '@server/db/webhook-deliveries'; type PersistedReviewJob = ReturnType; +export type ReviewJobRunResult = { action: 'ack' } | { action: 'retry'; delaySeconds: number }; + +const REVIEW_CHUNK_FILE_LIMIT = 2; +const REVIEW_CHUNK_WALL_CLOCK_MS = 8 * 60 * 1000; +const JOB_LEASE_SECONDS = 10 * 60; +const BUSY_RETRY_SECONDS = 60; + +function isRetryableFileReviewErrorMessage(message: string | null | undefined) { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes('all configured review models failed') || + lower.includes('retrying later') || + lower.includes('google request failed with 5') || + lower.includes('cloudflare') || + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('internal error') || + lower.includes('unavailable') || + lower.includes('high demand') || + lower.includes('temporary') || + lower.includes('[redacted]') || + lower.includes('returned no review content') || + lower.includes('empty response') + ); +} + +function shouldRetryExistingFileReview(review: { file_status: string; error_msg: string | null }) { + return review.file_status === 'failed' && isRetryableFileReviewErrorMessage(review.error_msg); +} + +function countsAsHandledFileReview(review: { file_status: string; error_msg: string | null }) { + return !shouldRetryExistingFileReview(review); +} + function shouldTriggerFromPullRequest(action: PullRequestWebhookPayload['action'], config: RepoConfig['review']) { return (config.on as string[]).includes(action); } @@ -94,481 +129,556 @@ export function extractReviewRequest(input: { return null; } -export async function runReviewJob(env: AppBindings, message: ReviewJobMessage) { - let job: PersistedReviewJob; - - if (message.jobId) { - const row = await getJobForProcessing(env, message.jobId); - if (!row) { - logger.warn(`Job not found for processing: ${message.jobId}`); - return; - } - - job = mapJob(row); - if (job.status === 'superseded') { - logger.info(`Job ${job.id} is superseded, skipping processing.`); - return; - } - if (job.status === 'running') { - logger.info(`Job ${job.id} is already running, skipping duplicate queue delivery.`); - return; - } - } else { - if (!message.eventName) { - logger.warn('Queue message ignored: missing eventName'); - return; - } +export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): Promise { + const resolved = await resolveQueuedJob(env, message); + if (!resolved) { + return { action: 'ack' }; + } - let eventName = message.eventName; - let payload = message.payload as GitHubWebhookPayload | undefined; + const leaseOwner = crypto.randomUUID(); + const claim = await claimJobLease(env, resolved.job.id, leaseOwner, JOB_LEASE_SECONDS); + if (claim.status === 'missing') { + logger.warn(`Job not found for processing: ${resolved.job.id}`); + return { action: 'ack' }; + } + if (claim.status === 'terminal') { + logger.info(`Job ${resolved.job.id} is already terminal (${claim.row.status}), acking queue delivery.`); + return { action: 'ack' }; + } + if (claim.status === 'busy') { + logger.info(`Job ${resolved.job.id} has a fresh lease; retrying queue delivery later.`); + return { action: 'retry', delaySeconds: Math.min(BUSY_RETRY_SECONDS, claim.retryAfterSeconds) }; + } - if (payload === undefined) { - const delivery = await getWebhookDelivery(env, message.deliveryId); - if (!delivery) { - logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`); - return; - } + const job = mapJob(claim.row); + const phase = resolved.phase; + const tracker = new TokenTracker(); + const github = new GitHubService(env, job.installationId, tracker); + const model = new ModelService(env, tracker); + const formatter = new FormatterService(env.APP_URL); - eventName = delivery.event_name; - payload = delivery.payload as GitHubWebhookPayload; + try { + if (phase === 'prepare') { + await runPreparePhase(env, job, leaseOwner, github); + } else if (phase === 'finalize') { + await runFinalizePhase(env, job, leaseOwner, github, model, formatter); + } else { + await runReviewPhase(env, job, leaseOwner, github, model); } - if (!isSupportedGitHubWebhookEvent(eventName)) { - logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); - return; + await releaseJobLease(env, job.id, leaseOwner); + return { action: 'ack' }; + } catch (error) { + const messageText = error instanceof Error ? error.message : 'Unknown review failure'; + if (messageText === 'JOB_SUPERSEDED') { + logger.info(`Job ${job.id} was superseded during execution, stopping.`); + await releaseJobLease(env, job.id, leaseOwner); + return { action: 'ack' }; } - const installationId = String(payload.installation?.id ?? ''); - if (!installationId || !('repository' in payload) || !payload.repository) { - logger.info('Queue message ignored: missing installation or repository info'); - return; + if (isRetryableModelError(error)) { + logger.warn(`Review job hit transient model/provider failure; retrying queue delivery: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + error: messageText, + }); + await releaseJobLease(env, job.id, leaseOwner); + return { action: 'retry', delaySeconds: 120 }; } - // 1. Load Repo Config - const repoConfig = await loadRepoConfig(env, { - installationId, - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); + logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); + await failJobAndCheckRun(env, job, github, messageText); + return { action: 'ack' }; + } +} - if (repoConfig.enabled === false) { - logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); - return; - } +async function resolveQueuedJob( + env: AppBindings, + message: ReviewJobMessage, +): Promise<{ job: PersistedReviewJob; phase: 'prepare' | 'review' | 'finalize' } | null> { + if (message.jobId) { + const row = await getJobForProcessing(env, message.jobId); + return row ? { job: mapJob(row), phase: message.phase ?? 'review' } : null; + } - // 2. Extract Review Request - const extracted = extractReviewRequest({ - eventName, - payload, - botUsername: env.BOT_USERNAME, - config: repoConfig.parsedJson, - }); + if (!message.eventName) { + logger.warn('Queue message ignored: missing eventName'); + return null; + } - if (!extracted) { - // Handle specific PR closed events if needed (cleanup) - if (eventName === 'pull_request') { - const prPayload = payload as PullRequestWebhookPayload; - if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { - const labels = repoConfig.parsedJson.review.labels; - const gh = new GitHubClient(env, installationId); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p1); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p2); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p3); - } - } - return; - } + let eventName = message.eventName; + let payload = message.payload as GitHubWebhookPayload | undefined; - // 3. Resolve full PR info for mentions - let resolved = extracted; - const githubClient = new GitHubClient(env, installationId); - if (eventName === 'issue_comment') { - const pr = await githubClient.getPullRequest(extracted.owner, extracted.repo, extracted.prNumber); - resolved = { - ...extracted, - prTitle: pr.title, - prAuthor: pr.user.login, - commitSha: pr.head.sha, - baseSha: pr.base.sha, - headRef: pr.head.ref, - baseRef: pr.base.ref, - }; + if (payload === undefined) { + const delivery = await getWebhookDelivery(env, message.deliveryId); + if (!delivery) { + logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`); + return null; } - // 4. Duplicate Check - const duplicateJob = await findExistingJobForHead(env, { - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - commitSha: resolved.commitSha, - trigger: resolved.trigger, - }); - if (duplicateJob) { - if (duplicateJob.status === 'running') { - logger.info(`Duplicate in-flight job ${duplicateJob.id} is already running for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}.`); - return; - } - if (duplicateJob.status === 'queued') { - logger.info(`Resuming duplicate in-flight job ${duplicateJob.id} for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}.`); - job = duplicateJob; - } else { - logger.info(`Duplicate terminal job found for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}, skipping.`); - return; - } - } else { - // 5. Insert Job - job = await insertJob(env, { - installationId: resolved.installationId, - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - prTitle: resolved.prTitle, - prAuthor: resolved.prAuthor, - commitSha: resolved.commitSha, - baseSha: resolved.baseSha, - trigger: resolved.trigger, - headRef: resolved.headRef, - baseRef: resolved.baseRef, - configSnapshot: repoConfig.parsedJson, - }); + eventName = delivery.event_name; + payload = delivery.payload as GitHubWebhookPayload; + } - // 6. Supersede older jobs - await supersedeOlderJobs(env, { - installationId: resolved.installationId, - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - newJobId: job.id, - }); - } + if (!isSupportedGitHubWebhookEvent(eventName)) { + logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); + return null; } - const tracker = new TokenTracker(); - const github = new GitHubService(env, job.installationId, tracker); - const model = new ModelService(env, tracker); - const formatter = new FormatterService(env.APP_URL); + const installationId = String(payload.installation?.id ?? ''); + if (!installationId || !('repository' in payload) || !payload.repository) { + logger.info('Queue message ignored: missing installation or repository info'); + return null; + } - let checkRunId = job.checkRunId; + const repoConfig = await loadRepoConfig(env, { + installationId, + owner: payload.repository.owner.login, + repo: payload.repository.name, + }); - try { - tracker.incrementSubrequests(1); - const claimed = await startJobProcessing(env, job.id, 'Preparation'); - if (!claimed) { - logger.info(`Job ${job.id} was already claimed or no longer queued, skipping duplicate queue delivery.`); - return; - } + if (repoConfig.enabled === false) { + logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); + return null; + } - const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); - const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + const extracted = extractReviewRequest({ + eventName, + payload, + botUsername: env.BOT_USERNAME, + config: repoConfig.parsedJson, + }); + + if (!extracted) { + if (eventName === 'pull_request') { + const prPayload = payload as PullRequestWebhookPayload; + if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { + const labels = repoConfig.parsedJson.review.labels; + const gh = new GitHubClient(env, installationId); + await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p1); + await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p2); + await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p3); + } + } + return null; + } - if (!checkRunId) { - const checkRun = await github.createCheckRun(job.owner, job.repo, { - headSha: pr.head.sha, - title: 'Review queued', - summary: 'Codra has started reviewing this pull request.', - }); - checkRunId = checkRun.id; + let resolved = extracted; + const githubClient = new GitHubClient(env, installationId); + if (eventName === 'issue_comment') { + const pr = await githubClient.getPullRequest(extracted.owner, extracted.repo, extracted.prNumber); + resolved = { + ...extracted, + prTitle: pr.title, + prAuthor: pr.user.login, + commitSha: pr.head.sha, + baseSha: pr.base.sha, + headRef: pr.head.ref, + baseRef: pr.base.ref, + }; + } - tracker.incrementSubrequests(1); - await updateJobCheckRun(env, job.id, checkRun.id); + const duplicateJob = await findExistingJobForHead(env, { + owner: resolved.owner, + repo: resolved.repo, + prNumber: resolved.prNumber, + commitSha: resolved.commitSha, + trigger: resolved.trigger, + }); + if (duplicateJob) { + if (duplicateJob.status === 'queued' || duplicateJob.status === 'running') { + logger.info(`Resuming duplicate in-flight job ${duplicateJob.id} for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}.`); + return { job: duplicateJob, phase: message.phase ?? 'prepare' }; } - const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); - const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); - - tracker.incrementSubrequests(1); - await completePreparationStep(env, job.id, files.length); + logger.info(`Duplicate terminal job found for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}, skipping.`); + return null; + } - tracker.incrementSubrequests(1); - const preparedJob = await getJobForProcessing(env, job.id); - if (preparedJob?.status === 'superseded') { - throw new Error('JOB_SUPERSEDED'); - } + const job = await insertJob(env, { + installationId: resolved.installationId, + owner: resolved.owner, + repo: resolved.repo, + prNumber: resolved.prNumber, + prTitle: resolved.prTitle, + prAuthor: resolved.prAuthor, + commitSha: resolved.commitSha, + baseSha: resolved.baseSha, + trigger: resolved.trigger, + headRef: resolved.headRef, + baseRef: resolved.baseRef, + configSnapshot: repoConfig.parsedJson, + }); + + await supersedeOlderJobs(env, { + installationId: resolved.installationId, + owner: resolved.owner, + repo: resolved.repo, + prNumber: resolved.prNumber, + newJobId: job.id, + }); + + return { job, phase: 'prepare' }; +} - tracker.incrementSubrequests(1); - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); - const reviewedComments: ParsedReviewComment[] = []; - const fileSummaries: Array<{ path: string; summary: string; verdict: string }> = []; - const newReviewsToInsert: any[] = []; - let stoppedBeforeAllFiles = false; - - const jobIdsToQuery = [job.id]; - if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId); - const allExistingReviews = await getFileReviewsForJobs(env, jobIdsToQuery); - - const currentJobReviews = allExistingReviews.filter(r => r.job_id === job.id); - const existingReviews = [...currentJobReviews]; - for (const r of allExistingReviews) { - if (r.job_id !== job.id && !existingReviews.some(er => er.file_path === r.file_path)) { - existingReviews.push(r); - } - } +async function runPreparePhase( + env: AppBindings, + job: PersistedReviewJob, + leaseOwner: string, + github: GitHubService, +) { + await updateJobStep(env, job.id, 'Preparation', { status: 'running' }); + const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); + const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; - const totalLineCount = files.reduce((sum, f) => sum + f.lineCount, 0); - for (const [index, file] of files.entries()) { - // Safety break to avoid hitting Cloudflare 50-subrequest limit - if (!tracker.hasRemainingSubrequests(5)) { - logger.warn(`Approaching subrequest limit (${tracker.getSubrequestCount()}), stopping review loop at file ${index + 1}/${files.length}`); - stoppedBeforeAllFiles = true; - break; - } + let checkRunId = job.checkRunId; + if (!checkRunId) { + const checkRun = await github.createCheckRun(job.owner, job.repo, { + headSha: pr.head.sha, + title: 'Review queued', + summary: 'Codra has started reviewing this pull request.', + }); + checkRunId = checkRun.id; + await updateJobCheckRun(env, job.id, checkRun.id); + } - // Periodic check for supersession (every 50 files - reduced frequency to save subrequests) - if (index % 50 === 0 && index > 0) { - tracker.incrementSubrequests(1); - const currentJob = await getJobForProcessing(env, job.id); - if (currentJob?.status === 'superseded') { - throw new Error('JOB_SUPERSEDED'); - } - } + const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); + const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); + await completePreparationStep(env, job.id, files.length); + await heartbeatJobLease(env, job.id, leaseOwner, JOB_LEASE_SECONDS); - const existing = existingReviews.find((r) => r.file_path === file.path && r.file_status === 'done'); - - if (existing) { - reviewedComments.push(...(existing.parsed_comments as ParsedReviewComment[])); - fileSummaries.push({ - path: file.path, - summary: existing.file_summary ?? '', - verdict: existing.verdict ?? 'comment', - }); - - if (existing.model_used && (existing.input_tokens || existing.output_tokens)) { - tracker.record(existing.model_used, existing.input_tokens ?? 0, existing.output_tokens ?? 0); - } - - // If this review was from a parent job, we'll include it in our batch insert for the current job - if (!currentJobReviews.some((r) => r.file_path === file.path)) { - newReviewsToInsert.push({ - filePath: file.path, - fileStatus: 'done', - modelUsed: existing.model_used, - modelProvider: (existing as any).model_provider, - diffLineCount: existing.diff_line_count, - diffInput: existing.diff_input, - rawAiOutput: existing.raw_ai_output, - parsedComments: existing.parsed_comments as ParsedReviewComment[], - inputTokens: existing.input_tokens, - outputTokens: existing.output_tokens, - durationMs: existing.duration_ms, - verdict: existing.verdict, - fileSummary: existing.file_summary, - overallCorrectness: existing.overall_correctness, - confidenceScore: existing.confidence_score, - errorMessage: null, - }); - } - continue; - } + if (files.length === 0) { + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await enqueueJobPhase(env, job.id, 'finalize'); + return; + } - // Update check run less frequently (every 50 files) - if ((index > 0 && index % 50 === 0) || index === files.length - 1) { - await github.updateCheckRun(job.owner, job.repo, checkRunId, { - title: `Reviewing (${index + 1}/${files.length})`, - summary: `Analyzing ${file.path}`, - }); - } + if (checkRunId) { + await github.updateCheckRun(job.owner, job.repo, checkRunId, { + title: `Reviewing (0/${files.length})`, + summary: 'Codra is analyzing changed files.', + }); + } + await enqueueJobPhase(env, job.id, 'review'); +} - const startedAt = Date.now(); - try { - // AI call (ModelService handles its own subrequest incrementing) - const response = await model.reviewFile({ - file, - prTitle: pr.title ?? null, - prDescription: pr.body ?? null, - config: config, - totalLineCount, - }); - - reviewedComments.push(...response.parsed.comments); - fileSummaries.push({ - path: file.path, - summary: response.parsed.fileSummary, - verdict: response.parsed.verdict, - }); - - newReviewsToInsert.push({ - filePath: file.path, - fileStatus: 'done', - modelUsed: response.modelUsed, - modelProvider: response.provider, - diffLineCount: file.lineCount, - diffInput: response.userPrompt, - rawAiOutput: response.rawText, - parsedComments: response.parsed.comments, - inputTokens: response.inputTokens, - outputTokens: response.outputTokens, - durationMs: Date.now() - startedAt, - verdict: response.parsed.verdict, - fileSummary: response.parsed.fileSummary, - overallCorrectness: response.parsed.overallCorrectness, - confidenceScore: response.parsed.confidenceScore, - errorMessage: null, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; - logger.error(`File review failed for ${file.path}`, { error }); - - // If we hit a hard limit (subrequests or neuron quota), STOP EVERYTHING. - const isHardLimit = - errorMessage.toLowerCase().includes('subrequest') || - errorMessage.includes('4006') || - errorMessage.toLowerCase().includes('allocation'); - - if (isHardLimit) { - throw error; - } - - fileSummaries.push({ - path: file.path, - summary: `Review failed: ${errorMessage}`, - verdict: 'failed', - }); - - newReviewsToInsert.push({ - filePath: file.path, - fileStatus: 'failed', - modelUsed: config.model?.main ?? 'gemma-4-31b-it', - modelProvider: (config.model?.main ?? 'gemma-4-31b-it').startsWith('@cf/') ? 'cloudflare' : 'google', - diffLineCount: file.lineCount, - diffInput: '', - rawAiOutput: null, - parsedComments: [], - inputTokens: null, - outputTokens: null, - durationMs: Date.now() - startedAt, - verdict: null, - fileSummary: null, - errorMessage, - }); - } - } +async function runReviewPhase( + env: AppBindings, + job: PersistedReviewJob, + leaseOwner: string, + github: GitHubService, + model: ModelService, +) { + if (!hasCompletedStep(job, 'Preparation')) { + await runPreparePhase(env, job, leaseOwner, github); + return; + } - // Batch insert all NEW or parent-inherited reviews at once (1 subrequest for reviews, 1 for comments) - if (newReviewsToInsert.length > 0) { - const { batchInsertFileReviews } = await import('@server/db/file-reviews'); - tracker.incrementSubrequests(2); // 1 for reviews, 1 for comments - await batchInsertFileReviews(env, job.id, newReviewsToInsert); + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); + + 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 totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0); + const startedAt = Date.now(); + let processedThisChunk = 0; + + const jobIdsToQuery = [job.id]; + if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId); + const allExistingReviews = await getFileReviewsForJobs(env, jobIdsToQuery); + const currentReviews = new Map(allExistingReviews.filter((review) => review.job_id === job.id).map((review) => [review.file_path, review])); + const parentReviews = new Map(allExistingReviews.filter((review) => review.job_id !== job.id && review.file_status === 'done').map((review) => [review.file_path, review])); + + for (const file of files) { + const existingReview = currentReviews.get(file.path); + if (existingReview && countsAsHandledFileReview(existingReview)) { + continue; } - if (stoppedBeforeAllFiles) { - tracker.incrementSubrequests(1); - await updateJobStep(env, job.id, 'Reviewing Files', { - status: 'failed', - error: 'Review stopped before all files were analyzed due to subrequest limits.', + const inherited = parentReviews.get(file.path); + if (inherited) { + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'done', + modelUsed: inherited.model_used, + modelProvider: inherited.model_provider, + diffLineCount: inherited.diff_line_count, + diffInput: inherited.diff_input, + rawAiOutput: inherited.raw_ai_output, + parsedComments: inherited.parsed_comments as ParsedReviewComment[], + inputTokens: inherited.input_tokens, + outputTokens: inherited.output_tokens, + durationMs: inherited.duration_ms, + verdict: inherited.verdict, + fileSummary: inherited.file_summary, + overallCorrectness: inherited.overall_correctness, + confidenceScore: inherited.confidence_score, + errorMessage: null, }); - throw new Error('Review stopped before all files were analyzed due to subrequest limits.'); + currentReviews.set(file.path, inherited); + processedThisChunk += 1; + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + } else { + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model); + currentReviews.set(file.path, true as any); + processedThisChunk += 1; + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); } - if (fileSummaries.length > 0 && fileSummaries.every((f) => f.verdict === 'failed')) { - tracker.incrementSubrequests(1); - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'failed', error: 'All files failed to review' }); - throw new Error('All files failed to review'); + if (processedThisChunk >= REVIEW_CHUNK_FILE_LIMIT || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { + break; } + } - tracker.incrementSubrequests(1); - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + const latestReviews = await getFileReviewsForJobs(env, [job.id]); + const reviewedPaths = new Set(latestReviews.filter(countsAsHandledFileReview).map((review) => review.file_path)); + const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length; - tracker.incrementSubrequests(1); - await updateJobStep(env, job.id, 'Generating Summary', { status: 'running' }); - const hasFailures = fileSummaries.some((f) => f.verdict === 'failed'); - const verdictSummary = formatter.summarizeVerdict(reviewedComments, hasFailures); + if (completedCount >= files.length) { + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await enqueueJobPhase(env, job.id, 'finalize'); + return; + } - // Final check before generating summary and posting review - const finalJobCheck = await getJobForProcessing(env, job.id); - if (finalJobCheck?.status === 'superseded') { - throw new Error('JOB_SUPERSEDED'); - } + 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.', + }); + } + await enqueueJobPhase(env, job.id, 'review'); +} - const summaryResponse = await model.generateSummary({ +async function reviewAndPersistFile( + env: AppBindings, + job: PersistedReviewJob, + file: ReturnType[number], + pr: Awaited>, + config: RepoConfig, + totalLineCount: number, + model: ModelService, +) { + const startedAt = Date.now(); + try { + const response = await model.reviewFile({ + file, prTitle: pr.title ?? null, - verdict: verdictSummary.verdict, - fileSummaries, + prDescription: pr.body ?? null, config, + totalLineCount, + }); + + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'done', + modelUsed: response.modelUsed, + modelProvider: response.provider, + diffLineCount: file.lineCount, + diffInput: response.userPrompt, + rawAiOutput: response.rawText, + parsedComments: response.parsed.comments, + inputTokens: response.inputTokens, + outputTokens: response.outputTokens, + durationMs: Date.now() - startedAt, + verdict: response.parsed.verdict, + fileSummary: response.parsed.fileSummary, + overallCorrectness: response.parsed.overallCorrectness, + confidenceScore: response.parsed.confidenceScore, + errorMessage: null, }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; - await updateJobStep(env, job.id, 'Generating Summary', { status: 'done' }); + if (isRetryableModelError(error)) { + logger.warn(`File review deferred for ${file.path}; transient model/provider failure will retry later`, { + error: errorMessage, + }); + throw error; + } - const formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME); + logger.error(`File review failed for ${file.path}`, { error }); - await updateJobStep(env, job.id, 'Completing', { status: 'running' }); - const review = await github.createReview(job.owner, job.repo, job.prNumber, { - commitSha: pr.head.sha, - event: formatter.toReviewEvent(verdictSummary.verdict), - body: formattedSummary, - comments: reviewedComments.map(c => ({ - path: c.path, - position: c.position ?? undefined, - body: formatter.formatInlineComment(c) - })), + const isHardLimit = + errorMessage.toLowerCase().includes('subrequest') || + errorMessage.includes('4006') || + errorMessage.toLowerCase().includes('allocation'); + + if (isHardLimit) { + throw error; + } + + const modelId = config.model?.main ?? 'gemma-4-31b-it'; + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'failed', + modelUsed: modelId, + modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + diffLineCount: file.lineCount, + diffInput: '', + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: Date.now() - startedAt, + verdict: null, + fileSummary: null, + errorMessage, }); + } +} - if (config.review.labels !== false) { - const labels = config.review.labels; - const labelMap = { - comment: { name: labels.p1, color: 'f79009' }, - approve: { name: labels.p2, color: '027a48' }, - } as const; - const label = labelMap[verdictSummary.verdict]; - - // Remove other verdict labels if they exist - const allPotentialLabels = [labels.p1, labels.p2, labels.p3]; - for (const l of allPotentialLabels) { - if (l !== label.name) { - await github.removeIssueLabel(job.owner, job.repo, job.prNumber, l); - } - } +async function runFinalizePhase( + env: AppBindings, + job: PersistedReviewJob, + leaseOwner: string, + github: GitHubService, + model: ModelService, + formatter: FormatterService, +) { + await updateJobStep(env, job.id, 'Generating Summary', { status: 'running' }); + + 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 reviews = await getFileReviewsForJobs(env, [job.id]); + + if (reviews.length < files.length) { + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); + await enqueueJobPhase(env, job.id, 'review'); + return; + } + + const reviewedComments = reviews.flatMap((review) => review.parsed_comments as ParsedReviewComment[]); + const fileSummaries = reviews.map((review) => ({ + path: review.file_path, + summary: review.file_status === 'failed' + ? `Review failed: ${review.error_msg ?? 'Unknown file review error'}` + : (review.file_summary ?? ''), + verdict: review.file_status === 'failed' ? 'failed' : (review.verdict ?? 'comment'), + })); + + if (fileSummaries.length > 0 && fileSummaries.every((file) => file.verdict === 'failed')) { + await updateJobStep(env, job.id, 'Generating Summary', { status: 'failed', error: 'All files failed to review' }); + throw new Error('All files failed to review'); + } - await github.ensureLabel(job.owner, job.repo, label.name, label.color); - await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); + const hasFailures = fileSummaries.some((file) => file.verdict === 'failed'); + const verdictSummary = formatter.summarizeVerdict(reviewedComments, hasFailures); + const summaryResponse = await model.generateSummary({ + prTitle: pr.title ?? null, + verdict: verdictSummary.verdict, + fileSummaries, + config, + }); + await updateJobStep(env, job.id, 'Generating Summary', { status: 'done' }); + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + + const formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME); + + await updateJobStep(env, job.id, 'Completing', { status: 'running' }); + const review = await github.createReview(job.owner, job.repo, job.prNumber, { + commitSha: pr.head.sha, + event: formatter.toReviewEvent(verdictSummary.verdict), + body: formattedSummary, + comments: reviewedComments.map(comment => ({ + path: comment.path, + position: comment.position ?? undefined, + body: formatter.formatInlineComment(comment), + })), + }); + + if (config.review.labels !== false) { + const labels = config.review.labels; + const labelMap = { + comment: { name: labels.p1, color: 'f79009' }, + approve: { name: labels.p2, color: '027a48' }, + } as const; + const label = labelMap[verdictSummary.verdict]; + + for (const possibleLabel of [labels.p1, labels.p2, labels.p3]) { + if (possibleLabel !== label.name) { + await github.removeIssueLabel(job.owner, job.repo, job.prNumber, possibleLabel); + } } - await github.updateCheckRun(job.owner, job.repo, checkRunId, { + await github.ensureLabel(job.owner, job.repo, label.name, label.color); + await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); + } + + if (job.checkRunId) { + await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { status: 'completed', conclusion: hasFailures ? 'failure' : (verdictSummary.verdict === 'approve' ? 'success' : 'neutral'), title: hasFailures ? 'Review partially failed' : (verdictSummary.verdict === 'approve' ? 'LGTM' : 'Comments posted'), summary: `${reviewedComments.length} inline comments across ${files.length} files.${hasFailures ? ' Some files failed to parse.' : ''}`, }); + } - const finalUsage = tracker.getTotalUsage(); - logger.info(`Final token usage for job ${job.id}:`, { - total: finalUsage, - breakdown: tracker.getBreakdown() - }); + const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0); + const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0); + await completeJob(env, job.id, { + verdict: verdictSummary.verdict, + fileCount: files.length, + commentCount: reviewedComments.length, + totalInputTokens: fileInputTokens + (summaryResponse.inputTokens ?? 0), + totalOutputTokens: fileOutputTokens + (summaryResponse.outputTokens ?? 0), + summaryMarkdown: formattedSummary, + reviewId: review.id, + summaryModel: summaryResponse.modelUsed, + }); + await updateJobStep(env, job.id, 'Completing', { status: 'done' }); + logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); +} - await completeJob(env, job.id, { - verdict: verdictSummary.verdict, - fileCount: files.length, - commentCount: reviewedComments.length, - totalInputTokens: finalUsage.input, - totalOutputTokens: finalUsage.output, - summaryMarkdown: formattedSummary, - reviewId: review.id, - summaryModel: summaryResponse.modelUsed, - }); - await updateJobStep(env, job.id, 'Completing', { status: 'done' }); - logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown review failure'; - if (message === 'JOB_SUPERSEDED') { - logger.info(`Job ${job.id} was superseded during execution, stopping.`); - return; - } +async function heartbeatAndCheckSuperseded(env: AppBindings, jobId: string, leaseOwner: string) { + await heartbeatJobLease(env, jobId, leaseOwner, JOB_LEASE_SECONDS); + const currentJob = await getJobForProcessing(env, jobId); + if (currentJob?.status === 'superseded') { + throw new Error('JOB_SUPERSEDED'); + } +} - logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); +async function enqueueJobPhase( + env: AppBindings, + jobId: string, + phase: 'prepare' | 'review' | 'finalize', + delaySeconds = 0, +) { + await markJobContinuationQueued(env, jobId); + await env.REVIEW_QUEUE.send( + { + jobId, + deliveryId: crypto.randomUUID(), + phase, + }, + delaySeconds > 0 ? { delaySeconds } : undefined, + ); +} - // Attempt to record failure, but don't crash if we are out of subrequests - try { - await failJob(env, job.id, message); - if (checkRunId) { - await github.updateCheckRun(job.owner, job.repo, checkRunId, { - status: 'completed', - conclusion: 'failure', - title: 'Review failed', - summary: message, - }); - } - } catch (innerError) { - logger.error('Failed to record job failure in DB/GitHub (likely subrequest limit reached)', innerError); +function hasCompletedStep(job: PersistedReviewJob, stepName: string) { + return job.steps.some((step) => step.name === stepName && step.status === 'done'); +} + +async function failJobAndCheckRun( + env: AppBindings, + job: PersistedReviewJob, + github: GitHubService, + message: string, +) { + try { + await failJob(env, job.id, message); + const latest = await getJobForProcessing(env, job.id); + const checkRunId = latest?.check_run_id ?? job.checkRunId; + if (checkRunId) { + await github.updateCheckRun(job.owner, job.repo, checkRunId, { + status: 'completed', + conclusion: 'failure', + title: 'Review failed', + summary: message, + }); + await markJobCheckRunCompleted(env, job.id); } + } catch (innerError) { + logger.error('Failed to record job failure in DB/GitHub', innerError); } } diff --git a/src/server/db/client.ts b/src/server/db/client.ts index 934d487..f37a6fd 100644 --- a/src/server/db/client.ts +++ b/src/server/db/client.ts @@ -13,13 +13,13 @@ function createDbClient(env: DbEnv): DbClient { const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5, fetch_types: false, - prepare: true, + prepare: false, onnotice: () => {}, }); return { async query(sqlText: string, params: unknown[] = []) { - return (await sql.unsafe(sqlText, params.map(normalizeParam) as any[], { prepare: true })) as T[]; + return (await sql.unsafe(sqlText, params.map(normalizeParam) as any[], { prepare: false })) as T[]; }, }; } diff --git a/src/server/db/file-reviews.ts b/src/server/db/file-reviews.ts index bc99aac..0110a28 100644 --- a/src/server/db/file-reviews.ts +++ b/src/server/db/file-reviews.ts @@ -93,6 +93,113 @@ export async function insertFileReview( } } +export async function upsertFileReview( + env: Pick, + jobId: string, + input: { + filePath: string; + fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + rawAiOutput: string | null; + parsedComments: ParsedReviewComment[]; + inputTokens: number | null; + outputTokens: number | null; + durationMs: number | null; + verdict: 'approve' | 'comment' | null; + fileSummary: string | null; + overallCorrectness?: string | null; + confidenceScore?: number | null; + errorMessage: string | null; + }, +) { + const [review] = await queryRows<{ id: string }>( + env, + ` + INSERT INTO file_reviews ( + job_id, + file_path, + file_status, + model_used, + diff_line_count, + diff_input, + raw_ai_output, + input_tokens, + output_tokens, + duration_ms, + verdict, + file_summary, + overall_correctness, + confidence_score, + error_msg, + model_provider + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + ON CONFLICT (job_id, file_path) DO UPDATE SET + file_status = EXCLUDED.file_status, + model_used = EXCLUDED.model_used, + diff_line_count = EXCLUDED.diff_line_count, + diff_input = EXCLUDED.diff_input, + raw_ai_output = EXCLUDED.raw_ai_output, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + duration_ms = EXCLUDED.duration_ms, + verdict = EXCLUDED.verdict, + file_summary = EXCLUDED.file_summary, + overall_correctness = EXCLUDED.overall_correctness, + confidence_score = EXCLUDED.confidence_score, + error_msg = EXCLUDED.error_msg, + model_provider = EXCLUDED.model_provider + RETURNING id + `, + [ + jobId, + input.filePath, + input.fileStatus, + input.modelUsed, + input.diffLineCount, + input.diffInput, + input.rawAiOutput, + input.inputTokens, + input.outputTokens, + input.durationMs, + input.verdict, + input.fileSummary, + input.overallCorrectness ?? null, + input.confidenceScore ?? null, + input.errorMessage, + input.modelProvider ?? null, + ], + ); + + await queryRows(env, 'DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); + + if (input.parsedComments.length > 0) { + await queryRows( + env, + ` + INSERT INTO review_comments ( + file_review_id, path, line, position, severity, category, title, body, code_suggestion + ) + SELECT $1::uuid, * FROM UNNEST($2::text[], $3::int[], $4::int[], $5::text[], $6::text[], $7::text[], $8::text[], $9::text[]) + `, + [ + review.id, + input.parsedComments.map(c => c.path), + input.parsedComments.map(c => c.line ?? null), + input.parsedComments.map(c => c.position ?? null), + input.parsedComments.map(c => c.severity), + input.parsedComments.map(c => c.category), + input.parsedComments.map(c => c.title), + input.parsedComments.map(c => c.body), + input.parsedComments.map(c => c.codeSuggestion ?? null), + ], + ); + } +} + export async function getModelUsageStats(env: Pick) { return queryRows<{ model_used: string; diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index 76e1944..c43bd4b 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -3,7 +3,7 @@ import { parseJsonColumn, queryRows } from './client'; import { defaultRepoConfig, jobDetailSchema, jobSummarySchema, repoConfigSchema, type RepoConfig } from '@shared/schema'; import { getOrCreateRepository } from './repositories'; -type JobRow = { +export type JobRow = { id: string; installation_id: string; owner: string; @@ -17,9 +17,15 @@ type JobRow = { status: 'queued' | 'running' | 'done' | 'failed' | 'superseded'; config_snapshot: { review?: RepoConfig['review']; model?: RepoConfig['model'] } | string | null; check_run_id: number | null; + check_run_completed_at: string | null; created_at: string; started_at: string | null; finished_at: string | null; + lease_owner: string | null; + lease_expires_at: string | null; + heartbeat_at: string | null; + recovery_count: number | null; + last_queue_message_at: string | null; total_input_tokens: number | null; total_output_tokens: number | null; verdict: 'approve' | 'comment' | null; @@ -50,6 +56,12 @@ type JobDetailRow = JobRow & { type ByteaValue = ArrayBuffer | ArrayBufferView | string; +export type JobLeaseClaim = + | { status: 'claimed'; row: JobRow } + | { status: 'busy'; row: JobRow; retryAfterSeconds: number } + | { status: 'terminal'; row: JobRow } + | { status: 'missing' }; + function hexToBytes(hex: string) { const bytes = new Uint8Array(hex.length / 2); for (let index = 0; index < bytes.length; index += 1) { @@ -366,6 +378,108 @@ export async function startJobProcessing(env: Pick, j return rows.length > 0; } +export async function claimJobLease( + env: Pick, + jobId: string, + leaseOwner: string, + leaseSeconds: number, +): Promise { + const [claimed] = await queryRows( + env, + ` + WITH claimed AS ( + UPDATE jobs + SET status = CASE WHEN status = 'queued' THEN 'running' ELSE status END, + started_at = COALESCE(started_at, now()), + lease_owner = $2, + lease_expires_at = now() + ($3 || ' seconds')::interval, + heartbeat_at = now(), + last_queue_message_at = now() + WHERE id = $1 + AND status IN ('queued', 'running') + AND ( + lease_expires_at IS NULL + OR lease_expires_at < now() + OR lease_owner = $2 + ) + RETURNING * + ) + SELECT c.*, r.owner, r.repo, r.installation_id + FROM claimed c + JOIN repositories r ON c.repository_id = r.id + `, + [jobId, leaseOwner, String(leaseSeconds)], + ); + + if (claimed) { + return { status: 'claimed', row: claimed }; + } + + const row = await getJobForProcessing(env, jobId); + if (!row) { + return { status: 'missing' }; + } + + if (!['queued', 'running'].includes(row.status)) { + return { status: 'terminal', row }; + } + + const expiresAt = row.lease_expires_at ? new Date(row.lease_expires_at).getTime() : 0; + const secondsUntilExpiry = Math.ceil((expiresAt - Date.now()) / 1000); + return { + status: 'busy', + row, + retryAfterSeconds: Math.max(15, Math.min(60, Number.isFinite(secondsUntilExpiry) ? secondsUntilExpiry : 60)), + }; +} + +export async function heartbeatJobLease( + env: Pick, + jobId: string, + leaseOwner: string, + leaseSeconds: number, +) { + await queryRows( + env, + ` + UPDATE jobs + SET heartbeat_at = now(), + lease_expires_at = now() + ($3 || ' seconds')::interval + WHERE id = $1 + AND lease_owner = $2 + AND status = 'running' + `, + [jobId, leaseOwner, String(leaseSeconds)], + ); +} + +export async function releaseJobLease(env: Pick, jobId: string, leaseOwner: string) { + await queryRows( + env, + ` + UPDATE jobs + SET lease_owner = NULL, + lease_expires_at = NULL + WHERE id = $1 + AND lease_owner = $2 + `, + [jobId, leaseOwner], + ); +} + +export async function markJobContinuationQueued(env: Pick, jobId: string) { + await queryRows( + env, + ` + UPDATE jobs + SET last_queue_message_at = now() + WHERE id = $1 + AND status = 'running' + `, + [jobId], + ); +} + export async function updateJobCheckRun(env: Pick, jobId: string, checkRunId: number) { await queryRows( env, @@ -399,6 +513,9 @@ export async function completeJob( UPDATE jobs SET status = 'done', finished_at = now(), + check_run_completed_at = now(), + lease_owner = NULL, + lease_expires_at = NULL, verdict = $2, file_count = $3, comment_count = $4, @@ -433,6 +550,8 @@ export async function failJob(env: Pick, jobId: strin UPDATE jobs SET status = 'failed', finished_at = now(), + lease_owner = NULL, + lease_expires_at = NULL, error_msg = $2, steps = CASE WHEN steps IS NOT NULL THEN ( @@ -452,6 +571,18 @@ export async function failJob(env: Pick, jobId: strin ); } +export async function markJobCheckRunCompleted(env: Pick, jobId: string) { + await queryRows( + env, + ` + UPDATE jobs + SET check_run_completed_at = now() + WHERE id = $1 + `, + [jobId], + ); +} + export async function updateJobFileCount(env: Pick, jobId: string, fileCount: number) { await queryRows( env, @@ -580,6 +711,126 @@ export async function recoverStaleJobs( return rows.length; } +export async function recoverExpiredJobLeases( + env: Pick, + maxRecoveryCount = 3, + unleasedGraceSeconds = 120, +) { + const requeued = await queryRows<{ id: string }>( + env, + ` + WITH expired AS ( + SELECT id + FROM jobs + WHERE status = 'running' + AND ( + ( + lease_expires_at IS NOT NULL + AND lease_expires_at < now() + ) + OR ( + lease_expires_at IS NULL + AND COALESCE(last_queue_message_at, heartbeat_at, started_at, created_at) < now() - ($2 || ' seconds')::interval + ) + ) + AND recovery_count < $1 + ORDER BY COALESCE(lease_expires_at, last_queue_message_at, heartbeat_at, started_at, created_at) ASC + LIMIT 25 + FOR UPDATE SKIP LOCKED + ) + UPDATE jobs j + SET lease_owner = NULL, + lease_expires_at = NULL, + heartbeat_at = NULL, + recovery_count = recovery_count + 1, + last_queue_message_at = now(), + error_msg = NULL + FROM expired + WHERE j.id = expired.id + RETURNING j.id + `, + [maxRecoveryCount, String(unleasedGraceSeconds)], + ); + + const failed = await queryRows( + env, + ` + WITH expired AS ( + SELECT id + FROM jobs + WHERE status = 'running' + AND ( + ( + lease_expires_at IS NOT NULL + AND lease_expires_at < now() + ) + OR ( + lease_expires_at IS NULL + AND COALESCE(last_queue_message_at, heartbeat_at, started_at, created_at) < now() - ($2 || ' seconds')::interval + ) + ) + AND recovery_count >= $1 + ORDER BY COALESCE(lease_expires_at, last_queue_message_at, heartbeat_at, started_at, created_at) ASC + LIMIT 25 + FOR UPDATE SKIP LOCKED + ), + updated AS ( + UPDATE jobs j + SET status = 'failed', + finished_at = now(), + lease_owner = NULL, + lease_expires_at = NULL, + heartbeat_at = NULL, + error_msg = 'Job timed out: worker crashed or was evicted.', + steps = CASE + WHEN steps IS NOT NULL THEN ( + SELECT jsonb_agg( + CASE + WHEN s->>'status' = 'running' + THEN s || jsonb_build_object('status', 'failed', 'finishedAt', now(), 'error', 'Job timed out: worker crashed or was evicted.') + ELSE s + END + ) FROM jsonb_array_elements(steps) s + ) + ELSE steps + END + FROM expired + WHERE j.id = expired.id + RETURNING j.* + ) + SELECT u.*, r.owner, r.repo, r.installation_id + FROM updated u + JOIN repositories r ON u.repository_id = r.id + `, + [maxRecoveryCount, String(unleasedGraceSeconds)], + ); + + return { + requeuedJobIds: requeued.map((row) => row.id), + failedJobs: failed, + }; +} + +export async function getTerminalJobsNeedingCheckRunCompletion( + env: Pick, + limit = 25, +) { + return queryRows( + env, + ` + SELECT j.*, r.owner, r.repo, r.installation_id + FROM jobs j + JOIN repositories r ON j.repository_id = r.id + WHERE j.status IN ('failed', 'superseded') + AND j.check_run_id IS NOT NULL + AND j.check_run_completed_at IS NULL + ORDER BY COALESCE(j.finished_at, j.started_at, j.created_at) ASC + LIMIT $1 + `, + [limit], + ); +} + export async function supersedeOlderJobs( env: Pick, input: { @@ -596,6 +847,8 @@ export async function supersedeOlderJobs( UPDATE jobs j SET status = 'superseded', finished_at = now(), + lease_owner = NULL, + lease_expires_at = NULL, error_msg = 'Superseded by a newer commit or job.' FROM repositories r WHERE j.repository_id = r.id diff --git a/src/server/env.ts b/src/server/env.ts index d591cb6..03a765b 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -5,7 +5,7 @@ export interface WorkersAiBinding { } export interface QueueProducer { - send(message: T): Promise; + send(message: T, options?: { delaySeconds?: number }): Promise; } export interface AssetsBinding { diff --git a/src/server/index.ts b/src/server/index.ts index df53f8e..be5157a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,19 +2,12 @@ import { createApp } from './app'; import { runReviewJob } from './core/review'; import type { AppBindings } from './env'; import { reviewJobMessageSchema } from '@shared/schema'; -import { recoverStaleJobs } from '@server/db/jobs'; import { logger } from '@server/core/logger'; import { runWithDb } from '@server/db/client'; +import { runOpportunisticJobMaintenance } from '@server/core/job-recovery'; const app = createApp(); -/** - * Jobs left in 'running' after a worker crash must be recovered before the - * next batch is processed. The threshold is set to 20 minutes — well above - * the longest expected review job but below Cloudflare's 30-minute CPU limit. - */ -const STALE_JOB_THRESHOLD_MINUTES = 20; - export default { fetch(request: Request, env: AppBindings, ctx: ExecutionContext) { return runWithDb(env, () => app.fetch(request, env, ctx)); @@ -22,44 +15,34 @@ export default { async queue(batch: MessageBatch, env: AppBindings, _ctx: ExecutionContext) { return runWithDb(env, async () => { - // ── Stale-job recovery ────────────────────────────────────────────────── - // Run once per batch. Any job that was 'running' for > threshold is a - // leftover from a previous crashed invocation; mark it failed now so the - // dashboard and future retries see an accurate state. - try { - const recovered = await recoverStaleJobs(env, STALE_JOB_THRESHOLD_MINUTES); - if (recovered > 0) { - logger.warn('Stale jobs recovered', { count: recovered, thresholdMinutes: STALE_JOB_THRESHOLD_MINUTES }); - } - } catch (err) { - // Non-fatal: log and continue processing the batch. - logger.error('Failed to recover stale jobs', err instanceof Error ? err : new Error(String(err))); - } + await runOpportunisticJobMaintenance(env); - // ── Process messages ──────────────────────────────────────────────────── - for (const message of batch.messages) { - const parseResult = reviewJobMessageSchema.safeParse(message.body); + for (const message of batch.messages) { + const parseResult = reviewJobMessageSchema.safeParse(message.body); - if (!parseResult.success) { - // Malformed message — cannot be retried meaningfully. Ack it so - // Cloudflare delivers it to the DLQ for inspection instead of burning - // retries on something that will never be valid. - logger.error('Invalid queue message schema; discarding message', { - body: message.body, - error: parseResult.error.flatten(), - }); - message.ack(); - continue; - } + if (!parseResult.success) { + logger.error('Invalid queue message schema; retrying so it can reach the DLQ', { + body: message.body, + error: parseResult.error.flatten(), + }); + message.retry(); + continue; + } - try { - await runReviewJob(env, parseResult.data); - message.ack(); - } catch (error) { - logger.error('Queue message processing failed; retrying', error instanceof Error ? error : new Error(String(error))); - message.retry(); + try { + const result = await runReviewJob(env, parseResult.data); + if (result.action === 'retry') { + message.retry({ delaySeconds: result.delaySeconds }); + } else { + message.ack(); + } + } catch (error) { + logger.error('Queue message processing failed; retrying', error instanceof Error ? error : new Error(String(error))); + message.retry(); + } } - } + + await runOpportunisticJobMaintenance(env); }); }, } satisfies ExportedHandler; diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index b0f20cc..52b831d 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -3,8 +3,51 @@ import type { AppBindings } from '@server/env'; import { TimeoutError } from '@server/core/timeout'; import type { ModelResponse } from './types'; -/** Max wall-clock time allowed for a single Workers-AI call (600 s). */ -const CLOUDFLARE_TIMEOUT_MS = 600_000; +/** Max wall-clock time allowed for a single Workers-AI call. */ +const CLOUDFLARE_TIMEOUT_MS = 45_000; +const CLOUDFLARE_MAX_RETRIES = 1; + +function isText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function extractMessageContent(content: unknown): string | null { + if (isText(content)) return content.trim(); + + if (Array.isArray(content)) { + const text = content + .map((part) => { + if (isText(part)) return part; + if (part && typeof part === 'object' && isText((part as any).text)) return (part as any).text; + return ''; + }) + .join('') + .trim(); + return text || null; + } + + return null; +} + +function extractCloudflareText(result: any, model: string): string { + if (isText(result)) return result.trim(); + if (isText(result?.response)) return result.response.trim(); + if (isText(result?.result?.response)) return result.result.response.trim(); + + const choice = result?.choices?.[0]; + const content = extractMessageContent(choice?.message?.content); + if (content) return content; + + const finishReason = choice?.finish_reason ?? choice?.stop_reason; + if (finishReason) { + throw new Error(`Cloudflare model ${model} returned no review content (finish_reason=${finishReason}).`); + } + if (isText(choice?.message?.reasoning) || isText(choice?.message?.reasoning_content)) { + throw new Error(`Cloudflare model ${model} returned reasoning without review content.`); + } + + throw new Error(`Cloudflare model ${model} returned an empty response.`); +} export async function reviewWithCloudflare( env: Pick, @@ -12,7 +55,7 @@ export async function reviewWithCloudflare( input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, ): Promise { - const maxRetries = 2; + const maxRetries = CLOUDFLARE_MAX_RETRIES; let lastError: any; for (let attempt = 0; attempt <= maxRetries; attempt++) { @@ -39,17 +82,14 @@ export async function reviewWithCloudflare( { role: 'user', content: input.userPrompt }, ], max_completion_tokens: 4096, + temperature: 0, }), timeoutPromise, ]); const durationMs = Date.now() - startTime; logger.info(`AI model ${model} responded in ${durationMs}ms`); - const rawText = - result?.response ?? - result?.result?.response ?? - result?.choices?.[0]?.message?.content ?? - (typeof result === 'string' ? result : JSON.stringify(result)); + const rawText = extractCloudflareText(result, model); return { rawText, diff --git a/src/server/models/google.ts b/src/server/models/google.ts index bdd1d30..53c20de 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -3,8 +3,9 @@ import type { AppBindings } from '@server/env'; import { withTimeout } from '@server/core/timeout'; import type { ModelResponse } from './types'; -/** Max wall-clock time allowed for a single Google AI Studio call (120 s). */ -const GOOGLE_TIMEOUT_MS = 120_000; +/** Max wall-clock time allowed for a single Google AI Studio call. */ +const GOOGLE_TIMEOUT_MS = 45_000; +const GOOGLE_MAX_RETRIES = 1; export async function reviewWithGoogle( env: Pick, @@ -15,7 +16,7 @@ export async function reviewWithGoogle( logger.info(`Calling Google AI model: ${model}`); const startTime = Date.now(); const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${env.GEMINI_API_KEY}`; - const maxRetries = 2; + const maxRetries = GOOGLE_MAX_RETRIES; let lastError: any; for (let attempt = 0; attempt <= maxRetries; attempt++) { diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index 1125d79..9fea8bf 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -3,11 +3,14 @@ import { defaultRepoConfig, jobsQuerySchema } from '@shared/schema'; import type { AppEnv } from '@server/env'; import { bytesToHex, getJobDetail, getJobForProcessing, insertJob, listJobs, mapJob, supersedeOlderJobs } from '@server/db/jobs'; import { jsonError } from '@server/core/http'; +import { runOpportunisticJobMaintenance } from '@server/core/job-recovery'; export function createJobsRouter() { const app = new Hono(); app.get('/', async (c) => { + await runOpportunisticJobMaintenance(c.env); + const rawQuery = c.req.query(); const query = jobsQuerySchema.parse(rawQuery); @@ -16,6 +19,8 @@ export function createJobsRouter() { }); app.get('/:id', async (c) => { + await runOpportunisticJobMaintenance(c.env); + const job = await getJobDetail(c.env, c.req.param('id')); if (!job) { return jsonError('Job not found.', 404); @@ -60,6 +65,7 @@ export function createJobsRouter() { await c.env.REVIEW_QUEUE.send({ jobId: job.id, deliveryId: crypto.randomUUID(), + phase: 'prepare', requestId: c.get('requestId'), }); diff --git a/src/server/routes/webhook.ts b/src/server/routes/webhook.ts index f1f7dc6..3241b73 100644 --- a/src/server/routes/webhook.ts +++ b/src/server/routes/webhook.ts @@ -115,6 +115,7 @@ export function createWebhookRouter() { await c.env.REVIEW_QUEUE.send({ jobId: job.id, deliveryId, + phase: 'prepare', requestId: c.get('requestId'), }); diff --git a/src/server/services/model.ts b/src/server/services/model.ts index afff3eb..0e82a2a 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -16,6 +16,22 @@ const MODEL_ALIASES: Record = { 'gemma-4-26b': 'gemma-4-26b-a4b-it', }; +export class RetryableModelError extends Error { + readonly retryable = true; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'RetryableModelError'; + if (cause !== undefined) { + (this as any).cause = cause; + } + } +} + +export function isRetryableModelError(error: unknown) { + return Boolean(error && typeof error === 'object' && (error as any).retryable === true); +} + function isCloudflareModel(model: string) { return model.startsWith('@cf/'); } @@ -38,6 +54,29 @@ function isGoogleRateLimitError(error: unknown) { return message.includes('429') || message.includes('RESOURCE_EXHAUSTED') || message.toLowerCase().includes('quota exceeded'); } +function isTransientModelFailure(error: unknown) { + if (isRetryableModelError(error)) return true; + if (isCloudflareAllocationError(error)) return false; + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + return ( + isGoogleRateLimitError(error) || + /\b50[0-9]\b/.test(message) || + lower.includes('internal error') || + lower.includes('unavailable') || + lower.includes('high demand') || + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('fetch failed') || + lower.includes('network') || + lower.includes('temporar') || + lower.includes('returned no review content') || + lower.includes('empty response') || + lower.includes('[redacted]') + ); +} + export class ModelService { constructor(private env: AppBindings, private tracker?: TokenTracker) {} @@ -110,6 +149,7 @@ export class ModelService { const modelsToTry = [primary, ...fallbacks]; let lastError: any; + let sawTransientFailure = false; const unavailableProviders = new Set(); for (const currentModel of modelsToTry) { if (isCloudflareModel(currentModel) && unavailableProviders.has('cloudflare')) { @@ -136,6 +176,9 @@ export class ModelService { }; } catch (error: any) { lastError = error; + if (isTransientModelFailure(error)) { + sawTransientFailure = true; + } attempts++; if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { unavailableProviders.add('cloudflare'); @@ -159,6 +202,14 @@ export class ModelService { } } + if (sawTransientFailure) { + const lastMessage = lastError instanceof Error ? lastError.message : String(lastError ?? 'Unknown model error'); + throw new RetryableModelError( + `All configured review models failed for ${params.file.path}; retrying later. Last error: ${lastMessage}`, + lastError, + ); + } + throw lastError; } diff --git a/src/shared/schema.ts b/src/shared/schema.ts index cf12a90..63bc503 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -145,6 +145,7 @@ export const repoConfigSchema = z.object({ export const reviewJobMessageSchema = z.object({ jobId: z.string().uuid().optional(), deliveryId: z.string().min(1), + phase: z.enum(['prepare', 'review', 'finalize']).optional(), eventName: z.string().min(1).optional(), payload: z.any().optional(), installationId: z.string().min(1).optional(), diff --git a/test/helpers.ts b/test/helpers.ts index a698918..70afc63 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -49,8 +49,8 @@ export class MockAssets { export class MockQueue { public readonly sent: any[] = []; - async send(message: any) { - this.sent.push(message); + async send(message: any, options?: { delaySeconds?: number }) { + this.sent.push({ ...message, options }); } } diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 22f522d..45a991d 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ModelService } from '@server/services/model'; +import { isRetryableModelError, ModelService } from '@server/services/model'; +import { reviewWithCloudflare } from '@server/models/cloudflare'; import { createTestEnv } from './helpers'; describe('ModelService', () => { @@ -23,4 +24,105 @@ describe('ModelService', () => { expect(requestedModel).toBe('@cf/moonshotai/kimi-k2.6'); expect(response.modelUsed).toBe('@cf/moonshotai/kimi-k2.6'); }); + + it('rejects Cloudflare reasoning-only responses instead of trying to parse the response envelope', async () => { + const env = createTestEnv({ + AI: { + async run() { + return { + choices: [ + { + message: { + content: null, + reasoning: 'Long reasoning that consumed the completion budget.', + }, + finish_reason: 'length', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 4096 }, + }; + }, + } as any, + }); + + await expect( + reviewWithCloudflare(env, '@cf/moonshotai/kimi-k2.6', { + systemPrompt: 'system', + userPrompt: 'user', + }), + ).rejects.toThrow('returned no review content'); + }); + + it('retries the same Cloudflare model once before failing it', async () => { + let attempts = 0; + const env = createTestEnv({ + AI: { + async run() { + attempts++; + throw new Error('temporary provider error'); + }, + } as any, + }); + + await expect( + reviewWithCloudflare(env, '@cf/zai-org/glm-4.7-flash', { + systemPrompt: 'system', + userPrompt: 'user', + }), + ).rejects.toThrow('temporary provider error'); + expect(attempts).toBe(2); + }); + + it('marks exhausted transient provider failures as retryable for the queue', async () => { + const env = createTestEnv({ + AI: { + async run() { + throw new Error('[REDACTED]'); + }, + } as any, + }); + + const service = new ModelService(env); + await expect( + service.reviewFile({ + file: { + path: 'test/setup.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }, + prTitle: 'Test', + prDescription: null, + config: { + review: { + on: ['opened'], + ignore_drafts: true, + mention_trigger: '@codra-app', + skip_files: [], + max_files: 15, + large_file_threshold_lines: 200, + max_diff_lines_per_file: 800, + max_total_diff_chars: 150_000, + focus: ['quality'], + custom_rules: [], + labels: false, + exec: { + enabled: false, + on_file_types: ['.ts'], + command: 'npm run lint', + }, + }, + model: { + main: '@cf/zai-org/glm-4.7-flash', + fallbacks: [], + size_overrides: [], + }, + }, + totalLineCount: 1, + }), + ).rejects.toSatisfy(isRetryableModelError); + }); }); diff --git a/test/resumable-queue.spec.ts b/test/resumable-queue.spec.ts new file mode 100644 index 0000000..f3b65f3 --- /dev/null +++ b/test/resumable-queue.spec.ts @@ -0,0 +1,204 @@ +import worker from '@server/index'; +import { claimJobLease, getJobForProcessing, insertJob, recoverExpiredJobLeases } from '@server/db/jobs'; +import { upsertFileReview, getFileReviewsForJobs } from '@server/db/file-reviews'; +import { getDb } from '@server/db/client'; +import { createTestEnv, hasConfiguredTestDatabaseUrl } from './helpers'; + +const sha = (char: string) => char.repeat(40); +const dbDescribe = hasConfiguredTestDatabaseUrl() ? describe : describe.skip; + +dbDescribe('resumable queue primitives', () => { + const env = createTestEnv(); + + it('sets a fresh lease when claiming a queued job', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `lease-${Date.now()}`, + prNumber: 1, + prTitle: 'Lease Test', + prAuthor: 'author', + commitSha: sha('a'), + baseSha: sha('b'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + const claim = await claimJobLease(env, job.id, 'lease-a', 600); + expect(claim.status).toBe('claimed'); + + const row = await getJobForProcessing(env, job.id); + expect(row?.status).toBe('running'); + expect(row?.lease_owner).toBe('lease-a'); + expect(row?.lease_expires_at).toBeTruthy(); + expect(row?.heartbeat_at).toBeTruthy(); + }); + + it('reports busy for a fresh duplicate delivery instead of reclaiming', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `busy-${Date.now()}`, + prNumber: 1, + prTitle: 'Busy Test', + prAuthor: 'author', + commitSha: sha('c'), + baseSha: sha('d'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + await claimJobLease(env, job.id, 'lease-a', 600); + const duplicate = await claimJobLease(env, job.id, 'lease-b', 600); + expect(duplicate.status).toBe('busy'); + }); + + it('reclaims an expired lease', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `expired-${Date.now()}`, + prNumber: 1, + prTitle: 'Expired Test', + prAuthor: 'author', + commitSha: sha('e'), + baseSha: sha('f'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + await claimJobLease(env, job.id, 'lease-a', 600); + await getDb(env).query(`UPDATE jobs SET lease_expires_at = now() - interval '1 minute' WHERE id = $1`, [job.id]); + + const reclaimed = await claimJobLease(env, job.id, 'lease-b', 600); + expect(reclaimed.status).toBe('claimed'); + + const row = await getJobForProcessing(env, job.id); + expect(row?.lease_owner).toBe('lease-b'); + }); + + it('fails repeatedly expired jobs after the recovery limit', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `recovery-${Date.now()}`, + prNumber: 1, + prTitle: 'Recovery Test', + prAuthor: 'author', + commitSha: sha('1'), + baseSha: sha('2'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + await claimJobLease(env, job.id, 'lease-a', 600); + await getDb(env).query( + `UPDATE jobs SET lease_expires_at = now() - interval '1 minute', recovery_count = 3 WHERE id = $1`, + [job.id], + ); + + const recovered = await recoverExpiredJobLeases(env, 3); + expect(recovered.failedJobs.map((row) => row.id)).toContain(job.id); + + const row = await getJobForProcessing(env, job.id); + expect(row?.status).toBe('failed'); + }); + + it('requeues running jobs that have no lease and an old continuation handoff', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `unleased-${Date.now()}`, + prNumber: 1, + prTitle: 'Unleased Test', + prAuthor: 'author', + commitSha: sha('5'), + baseSha: sha('6'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + await claimJobLease(env, job.id, 'lease-a', 600); + await getDb(env).query( + ` + UPDATE jobs + SET lease_owner = NULL, + lease_expires_at = NULL, + heartbeat_at = now() - interval '5 minutes', + last_queue_message_at = now() - interval '5 minutes' + WHERE id = $1 + `, + [job.id], + ); + + const recovered = await recoverExpiredJobLeases(env, 3, 120); + expect(recovered.requeuedJobIds).toContain(job.id); + + const row = await getJobForProcessing(env, job.id); + expect(row?.status).toBe('running'); + expect(row?.lease_owner).toBeNull(); + expect(row?.recovery_count).toBe(1); + expect(row?.error_msg).toBeNull(); + }); + + it('upserts file reviews without duplicating the same file', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `upsert-${Date.now()}`, + prNumber: 1, + prTitle: 'Upsert Test', + prAuthor: 'author', + commitSha: sha('3'), + baseSha: sha('4'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + const baseReview = { + filePath: 'src/app.ts', + fileStatus: 'done' as const, + modelUsed: 'test-model', + modelProvider: 'test-provider', + diffLineCount: 1, + diffInput: 'diff', + rawAiOutput: '{}', + parsedComments: [], + inputTokens: 1, + outputTokens: 1, + durationMs: 1, + verdict: 'approve' as const, + fileSummary: 'ok', + errorMessage: null, + }; + + await upsertFileReview(env, job.id, baseReview); + await upsertFileReview(env, job.id, { ...baseReview, fileSummary: 'updated' }); + + const reviews = await getFileReviewsForJobs(env, [job.id]); + expect(reviews).toHaveLength(1); + expect(reviews[0].file_summary).toBe('updated'); + }); +}); + +describe('queue handler', () => { + it('retries invalid messages instead of acknowledging them', async () => { + const env = createTestEnv(); + const message = { + body: { bad: true }, + ack: vi.fn(), + retry: vi.fn(), + }; + + await worker.queue({ messages: [message] } as any, env, {} as ExecutionContext); + + expect(message.retry).toHaveBeenCalledTimes(1); + expect(message.ack).not.toHaveBeenCalled(); + }); +}); diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index 76e5579..4bc7227 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -3,6 +3,7 @@ import { createTestEnv, generateMockDiff, hasConfiguredTestDatabaseUrl } from '. import { vi } from 'vitest'; import { findExistingJobForHead, getJobForProcessing, insertJob } from '@server/db/jobs'; import { defaultRepoConfig } from '@shared/schema'; +import { runWithDb } from '@server/db/client'; const sha = (char: string) => char.repeat(40); @@ -65,20 +66,36 @@ vi.mock('@server/services/model', () => { }; } } - return { ModelService: MockModelService }; + return { + ModelService: MockModelService, + isRetryableModelError: (error: unknown) => Boolean(error && typeof error === 'object' && (error as any).retryable === true), + }; }); const dbDescribe = hasConfiguredTestDatabaseUrl() ? describe : describe.skip; +const REVIEW_FLOW_TIMEOUT_MS = 60_000; dbDescribe('Review Flow Lifecycle', () => { const env = createTestEnv(); + async function runAndDrain(message: Parameters[1]) { + await runWithDb(env, async () => { + (env.REVIEW_QUEUE as any).sent.length = 0; + await runReviewJob(env, message); + const queue = env.REVIEW_QUEUE as any; + while (queue.sent.length > 0) { + const next = queue.sent.shift(); + await runReviewJob(env, next); + } + }); + } + it('completes a full review from pending job to finished', async () => { const repo = `test-repo-${Date.now()}-full`; const headSha = sha('a'); const baseSha = sha('b'); - await runReviewJob(env, { + await runAndDrain({ deliveryId: 'delivery-123', eventName: 'pull_request', payload: { @@ -104,7 +121,7 @@ dbDescribe('Review Flow Lifecycle', () => { trigger: 'auto', }); expect(finalJob?.status).toBe('done'); - }); + }, REVIEW_FLOW_TIMEOUT_MS); it('stops processing if the job is superseded mid-way', async () => { const { GitHubService } = await import('@server/services/github'); @@ -133,7 +150,7 @@ dbDescribe('Review Flow Lifecycle', () => { return generateMockDiff([{ path: 'test.ts', content: 'a' }]); }); - await runReviewJob(env, { + await runAndDrain({ deliveryId: 'delivery-456', eventName: 'pull_request', payload: { @@ -160,7 +177,7 @@ dbDescribe('Review Flow Lifecycle', () => { }); expect(finalJob?.status).toBe('superseded'); expect(finalJob?.verdict).toBeNull(); - }); + }, REVIEW_FLOW_TIMEOUT_MS); it('processes a pre-created retry job from a queue message', async () => { const repo = `test-repo-${Date.now()}-retry`; @@ -199,14 +216,14 @@ dbDescribe('Review Flow Lifecycle', () => { retryOfJobId: source.id, }); - await runReviewJob(env, { + await runAndDrain({ jobId: retry.id, deliveryId: 'delivery-retry', }); const finalJob = await getJobForProcessing(env, retry.id); expect(finalJob?.status).toBe('done'); - }); + }, REVIEW_FLOW_TIMEOUT_MS); it('resumes an existing queued duplicate job instead of stranding it', async () => { const repo = `test-repo-${Date.now()}-duplicate`; @@ -228,7 +245,7 @@ dbDescribe('Review Flow Lifecycle', () => { configSnapshot: defaultRepoConfig, }); - await runReviewJob(env, { + await runAndDrain({ deliveryId: 'delivery-duplicate', eventName: 'pull_request', payload: { @@ -248,5 +265,5 @@ dbDescribe('Review Flow Lifecycle', () => { const finalJob = await getJobForProcessing(env, existing.id); expect(finalJob?.status).toBe('done'); - }); + }, REVIEW_FLOW_TIMEOUT_MS); }); diff --git a/test/webhook-handling.spec.ts b/test/webhook-handling.spec.ts index cc425c8..2ad025b 100644 --- a/test/webhook-handling.spec.ts +++ b/test/webhook-handling.spec.ts @@ -116,6 +116,7 @@ describe('Webhook Handling Suite', () => { expect(queue.sent).toHaveLength(1); expect(queue.sent[0].jobId).toBe(json.job.id); expect(queue.sent[0].deliveryId).toBeDefined(); + expect(queue.sent[0].phase).toBe('prepare'); expect(queue.sent[0].eventName).toBeUndefined(); expect(queue.sent[0].payload).toBeUndefined(); }); From f89d03ec018c7a4fe3088cef379fb2f47d8e9e59 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 21 May 2026 22:08:11 +0530 Subject: [PATCH 03/32] add: retry mechanism for transient model provider failures --- db/migrations/002_resumable_queue_jobs.sql | 1 + scripts/test.mjs | 2 +- src/client/pages/settings.tsx | 6 +- src/server/core/review.ts | 124 +++++++++++++++---- src/server/db/file-reviews.ts | 74 ++++++++++- src/server/db/jobs.ts | 2 +- src/server/routes/api/jobs.ts | 10 +- src/server/services/model.ts | 14 ++- test/api.spec.ts | 70 ++++++++++- test/helpers.ts | 4 + test/model-service.spec.ts | 21 ++++ test/resumable-queue.spec.ts | 93 +++++++++++++- test/review-flow.spec.ts | 136 ++++++++++++++++++++- test/settings.spec.ts | 21 ++++ 14 files changed, 539 insertions(+), 39 deletions(-) create mode 100644 test/settings.spec.ts diff --git a/db/migrations/002_resumable_queue_jobs.sql b/db/migrations/002_resumable_queue_jobs.sql index c9afeb3..b521486 100644 --- a/db/migrations/002_resumable_queue_jobs.sql +++ b/db/migrations/002_resumable_queue_jobs.sql @@ -4,6 +4,7 @@ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx ON jobs (lease_expires_at) diff --git a/scripts/test.mjs b/scripts/test.mjs index 0eeed33..804fba1 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -71,7 +71,7 @@ if (!usableEnvValue(process.env.TEST_DATABASE_URL)) { process.exit(1); } -process.env.DATABASE_URL = usableEnvValue(process.env.DATABASE_URL) ?? process.env.TEST_DATABASE_URL; +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; run(process.execPath, ['scripts/migrate.mjs']); run(process.execPath, ['node_modules/vitest/vitest.mjs', 'run']); diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index 5394c67..cc75810 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -39,12 +39,12 @@ const DEFAULT_GLOBAL_CONFIG: ModelRouteConfig = { ], }; -function normalizeGlobalConfig(config: any): ModelRouteConfig { +export function normalizeGlobalConfig(config: any): ModelRouteConfig { if (!config || !config.main) return DEFAULT_GLOBAL_CONFIG; return { main: config.main, - fallbacks: config.fallbacks?.length ? config.fallbacks : DEFAULT_GLOBAL_CONFIG.fallbacks, - size_overrides: config.size_overrides ?? DEFAULT_GLOBAL_CONFIG.size_overrides, + fallbacks: Array.isArray(config.fallbacks) ? config.fallbacks : DEFAULT_GLOBAL_CONFIG.fallbacks, + size_overrides: Array.isArray(config.size_overrides) ? config.size_overrides : DEFAULT_GLOBAL_CONFIG.size_overrides, }; } diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 3a5db1e..bd2d163 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -1,8 +1,8 @@ import { logger } from './logger'; import { isSupportedGitHubWebhookEvent, type GitHubWebhookEventName, type GitHubWebhookPayload, type IssueCommentWebhookPayload, type PullRequestWebhookPayload } from '@shared/github'; -import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema'; +import { defaultRepoConfig, normalizeModelId, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema'; import type { AppBindings } from '@server/env'; -import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; +import { getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; import { claimJobLease, completeJob, completePreparationStep, failJob, findExistingJobForHead, getJobForProcessing, heartbeatJobLease, insertJob, mapJob, markJobCheckRunCompleted, markJobContinuationQueued, releaseJobLease, supersedeOlderJobs, updateJobCheckRun, updateJobStep } from '@server/db/jobs'; import { filterReviewableFiles, parseUnifiedDiff } from './diff'; @@ -22,6 +22,8 @@ const REVIEW_CHUNK_FILE_LIMIT = 2; const REVIEW_CHUNK_WALL_CLOCK_MS = 8 * 60 * 1000; const JOB_LEASE_SECONDS = 10 * 60; const BUSY_RETRY_SECONDS = 60; +const RETRYABLE_MODEL_FAILURE_RETRY_SECONDS = 60; +const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 3; function isRetryableFileReviewErrorMessage(message: string | null | undefined) { if (!message) return false; @@ -51,6 +53,30 @@ function countsAsHandledFileReview(review: { file_status: string; error_msg: str return !shouldRetryExistingFileReview(review); } +function configuredModelSet(config: RepoConfig) { + const models = new Set(); + const addModel = (model: string | null | undefined) => { + if (model) models.add(normalizeModelId(model)); + }; + + addModel(config.model?.main ?? 'gemma-4-31b-it'); + for (const fallback of config.model?.fallbacks ?? []) { + addModel(fallback); + } + for (const tier of config.model?.size_overrides ?? []) { + addModel(tier.model); + for (const fallback of tier.fallbacks ?? []) { + addModel(fallback); + } + } + + return models; +} + +function canInheritParentFileReview(config: RepoConfig, review: { model_used: string }) { + return configuredModelSet(config).has(normalizeModelId(review.model_used)); +} + function shouldTriggerFromPullRequest(action: PullRequestWebhookPayload['action'], config: RepoConfig['review']) { return (config.on as string[]).includes(action); } @@ -177,11 +203,14 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): } if (isRetryableModelError(error)) { - logger.warn(`Review job hit transient model/provider failure; retrying queue delivery: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + logger.warn(`Review job hit transient model/provider failure; scheduling delayed continuation: ${job.owner}/${job.repo} PR #${job.prNumber}`, { error: messageText, + phase, + delaySeconds: RETRYABLE_MODEL_FAILURE_RETRY_SECONDS, }); + await enqueueJobPhase(env, job.id, phase, RETRYABLE_MODEL_FAILURE_RETRY_SECONDS); await releaseJobLease(env, job.id, leaseOwner); - return { action: 'retry', delaySeconds: 120 }; + return { action: 'ack' }; } logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); @@ -396,27 +425,35 @@ async function runReviewPhase( const inherited = parentReviews.get(file.path); if (inherited) { - await upsertFileReview(env, job.id, { - filePath: file.path, - fileStatus: 'done', - modelUsed: inherited.model_used, - modelProvider: inherited.model_provider, - diffLineCount: inherited.diff_line_count, - diffInput: inherited.diff_input, - rawAiOutput: inherited.raw_ai_output, - parsedComments: inherited.parsed_comments as ParsedReviewComment[], - inputTokens: inherited.input_tokens, - outputTokens: inherited.output_tokens, - durationMs: inherited.duration_ms, - verdict: inherited.verdict, - fileSummary: inherited.file_summary, - overallCorrectness: inherited.overall_correctness, - confidenceScore: inherited.confidence_score, - errorMessage: null, - }); - currentReviews.set(file.path, inherited); - processedThisChunk += 1; - await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + if (!canInheritParentFileReview(config, inherited)) { + logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model); + currentReviews.set(file.path, true as any); + processedThisChunk += 1; + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + } else { + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'done', + modelUsed: inherited.model_used, + modelProvider: inherited.model_provider, + diffLineCount: inherited.diff_line_count, + diffInput: inherited.diff_input, + rawAiOutput: inherited.raw_ai_output, + parsedComments: inherited.parsed_comments as ParsedReviewComment[], + inputTokens: inherited.input_tokens, + outputTokens: inherited.output_tokens, + durationMs: inherited.duration_ms, + verdict: inherited.verdict, + fileSummary: inherited.file_summary, + overallCorrectness: inherited.overall_correctness, + confidenceScore: inherited.confidence_score, + errorMessage: null, + }); + currentReviews.set(file.path, inherited); + processedThisChunk += 1; + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + } } else { await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model); currentReviews.set(file.path, true as any); @@ -489,8 +526,45 @@ async function reviewAndPersistFile( const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; if (isRetryableModelError(error)) { + const modelId = config.model?.main ?? 'gemma-4-31b-it'; + const failureCount = await recordRetryableFileReviewFailure(env, job.id, { + filePath: file.path, + modelUsed: modelId, + modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + diffLineCount: file.lineCount, + diffInput: '', + durationMs: Date.now() - startedAt, + errorMessage, + }); + + if (failureCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES) { + const finalError = `Review skipped after ${failureCount} repeated model provider outages.`; + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'failed', + modelUsed: modelId, + modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + diffLineCount: file.lineCount, + diffInput: '', + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: Date.now() - startedAt, + verdict: null, + fileSummary: null, + errorMessage: finalError, + }); + logger.error(`File review failed permanently for ${file.path} after transient retries`, { + attempts: failureCount, + error: errorMessage, + }); + return; + } + logger.warn(`File review deferred for ${file.path}; transient model/provider failure will retry later`, { error: errorMessage, + attempts: failureCount, }); throw error; } diff --git a/src/server/db/file-reviews.ts b/src/server/db/file-reviews.ts index 0110a28..19d2ff1 100644 --- a/src/server/db/file-reviews.ts +++ b/src/server/db/file-reviews.ts @@ -151,7 +151,8 @@ export async function upsertFileReview( overall_correctness = EXCLUDED.overall_correctness, confidence_score = EXCLUDED.confidence_score, error_msg = EXCLUDED.error_msg, - model_provider = EXCLUDED.model_provider + model_provider = EXCLUDED.model_provider, + transient_error_count = 0 RETURNING id `, [ @@ -200,6 +201,76 @@ export async function upsertFileReview( } } +export async function recordRetryableFileReviewFailure( + env: Pick, + jobId: string, + input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + durationMs: number | null; + errorMessage: string; + }, +) { + const [review] = await queryRows<{ id: string; transient_error_count: number }>( + env, + ` + INSERT INTO file_reviews ( + job_id, + file_path, + file_status, + model_used, + model_provider, + diff_line_count, + diff_input, + raw_ai_output, + input_tokens, + output_tokens, + duration_ms, + verdict, + file_summary, + overall_correctness, + confidence_score, + error_msg, + transient_error_count + ) + VALUES ($1::uuid, $2, 'failed', $3, $4, $5, $6, NULL, NULL, NULL, $7, NULL, NULL, NULL, NULL, $8, 1) + ON CONFLICT (job_id, file_path) DO UPDATE SET + file_status = 'failed', + model_used = EXCLUDED.model_used, + model_provider = EXCLUDED.model_provider, + diff_line_count = EXCLUDED.diff_line_count, + diff_input = EXCLUDED.diff_input, + raw_ai_output = NULL, + input_tokens = NULL, + output_tokens = NULL, + duration_ms = EXCLUDED.duration_ms, + verdict = NULL, + file_summary = NULL, + overall_correctness = NULL, + confidence_score = NULL, + error_msg = EXCLUDED.error_msg, + transient_error_count = file_reviews.transient_error_count + 1 + RETURNING id, transient_error_count + `, + [ + jobId, + input.filePath, + input.modelUsed, + input.modelProvider ?? null, + input.diffLineCount, + input.diffInput, + input.durationMs, + input.errorMessage, + ], + ); + + await queryRows(env, 'DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); + return review.transient_error_count; +} + export async function getModelUsageStats(env: Pick) { return queryRows<{ model_used: string; @@ -369,6 +440,7 @@ export async function getFileReviewsForJobs(env: Pick confidence_score: number | null; error_msg: string | null; model_provider: string | null; + transient_error_count: number; }>( env, ` diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index c43bd4b..52941bc 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -714,7 +714,7 @@ export async function recoverStaleJobs( export async function recoverExpiredJobLeases( env: Pick, maxRecoveryCount = 3, - unleasedGraceSeconds = 120, + unleasedGraceSeconds = 300, ) { const requeued = await queryRows<{ id: string }>( env, diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index 9fea8bf..87174a2 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -1,9 +1,10 @@ import { Hono } from 'hono'; -import { defaultRepoConfig, jobsQuerySchema } from '@shared/schema'; +import { jobsQuerySchema } from '@shared/schema'; import type { AppEnv } from '@server/env'; import { bytesToHex, getJobDetail, getJobForProcessing, insertJob, listJobs, mapJob, supersedeOlderJobs } from '@server/db/jobs'; import { jsonError } from '@server/core/http'; import { runOpportunisticJobMaintenance } from '@server/core/job-recovery'; +import { loadRepoConfig } from '@server/core/config'; export function createJobsRouter() { const app = new Hono(); @@ -35,6 +36,11 @@ export function createJobsRouter() { return jsonError('Job not found.', 404); } const source = mapJob(rawSource); + const currentConfig = await loadRepoConfig(c.env, { + installationId: source.installationId, + owner: source.owner, + repo: source.repo, + }); const job = await insertJob(c.env, { installationId: source.installationId, @@ -48,7 +54,7 @@ export function createJobsRouter() { trigger: 'retry', headRef: rawSource.head_ref, baseRef: rawSource.base_ref, - configSnapshot: source.configSnapshot ?? defaultRepoConfig, + configSnapshot: currentConfig.parsedJson, retryOfJobId: source.id, }); diff --git a/src/server/services/model.ts b/src/server/services/model.ts index 0e82a2a..663ca3e 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -158,7 +158,7 @@ export class ModelService { } let attempts = 0; - const maxAttempts = 2; + const maxAttempts = 1; while (attempts < maxAttempts) { try { @@ -223,6 +223,7 @@ export class ModelService { const modelsToTry = [primary, ...fallbacks]; let lastError: any; + let sawTransientFailure = false; const unavailableProviders = new Set(); for (const currentModel of modelsToTry) { if (isCloudflareModel(currentModel) && unavailableProviders.has('cloudflare')) { @@ -243,6 +244,9 @@ export class ModelService { return response; } catch (error: any) { lastError = error; + if (isTransientModelFailure(error)) { + sawTransientFailure = true; + } if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { unavailableProviders.add('cloudflare'); } @@ -250,6 +254,14 @@ export class ModelService { } } + if (sawTransientFailure) { + const lastMessage = lastError instanceof Error ? lastError.message : String(lastError ?? 'Unknown model error'); + throw new RetryableModelError( + `All configured summary models failed; retrying later. Last error: ${lastMessage}`, + lastError, + ); + } + throw lastError; } } diff --git a/test/api.spec.ts b/test/api.spec.ts index 1b0ccd5..ce29724 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -1,5 +1,5 @@ import { createApp } from '@server/app'; -import { insertJob } from '@server/db/jobs'; +import { getJobForProcessing, insertJob } from '@server/db/jobs'; import { insertFileReview } from '@server/db/file-reviews'; import { getRepoConfigRecord } from '@server/db/repo-configs'; import { loadRepoConfig, updateGlobalConfig } from '@server/core/config'; @@ -545,6 +545,7 @@ describe('Dashboard API Suite', () => { }); expect(loaded.parsedJson.model.main).toBe('@cf/zai-org/glm-4.7-flash'); + expect(loaded.parsedJson.model.fallbacks).toEqual([]); const record = await getRepoConfigRecord(env, 'api-test-owner', repo); expect(record?.mainModel).toBeNull(); @@ -566,6 +567,73 @@ describe('Dashboard API Suite', () => { expect(reloaded.parsedJson.model.main).toBe('gemma-4-26b-a4b-it'); }); + it('uses the current global model strategy when retrying an older job', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + const repo = `retry-current-config-${Date.now()}`; + + const source = await insertJob(env, { + installationId: '123', + owner: 'api-test-owner', + repo, + prNumber: 12, + prTitle: 'Retry Current Config', + prAuthor: 'author', + commitSha: 'a'.repeat(40), + baseSha: 'b'.repeat(40), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + configSnapshot: { + ...defaultRepoConfig, + model: { + main: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], + size_overrides: [], + }, + }, + }); + + await updateGlobalConfig(env, { + main: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it'], + size_overrides: [ + { + max_lines: 300, + model: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it'], + }, + ], + }); + + const response = await app.request(`/api/jobs/${source.id}/retry`, { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(202); + const body = await response.json() as { job: { id: string } }; + const retry = await getJobForProcessing(env, body.job.id); + const snapshot = typeof retry?.config_snapshot === 'string' + ? JSON.parse(retry.config_snapshot) + : retry?.config_snapshot; + + expect(snapshot.model).toEqual({ + main: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it'], + size_overrides: [ + { + max_lines: 300, + model: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it'], + }, + ], + }); + }); + it('accepts legacy jobId-only queue messages during schema transition', () => { const parsed = reviewJobMessageSchema.safeParse({ jobId: crypto.randomUUID(), diff --git a/test/helpers.ts b/test/helpers.ts index 05fcc9b..440fd50 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -74,6 +74,10 @@ export function getTestDatabaseUrl() { return requiredEnv('TEST_DATABASE_URL'); } +export function hasConfiguredTestDatabaseUrl() { + return Boolean(usableEnvValue(process.env.TEST_DATABASE_URL)); +} + export function createTestEnv(overrides: Partial = {}): AppBindings { return { AI: { diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 45a991d..fa16c2d 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; import { reviewWithCloudflare } from '@server/models/cloudflare'; import { createTestEnv } from './helpers'; +import { defaultRepoConfig } from '@shared/schema'; describe('ModelService', () => { it('routes legacy Kimi K2.5 ids to Kimi K2.6 for new Cloudflare requests', async () => { @@ -25,6 +26,26 @@ describe('ModelService', () => { expect(response.modelUsed).toBe('@cf/moonshotai/kimi-k2.6'); }); + it('preserves an explicitly empty fallback chain', () => { + const service = new ModelService(createTestEnv()); + const selected = (service as any).selectModel({ + totalLineCount: 500, + config: { + ...defaultRepoConfig, + model: { + main: 'gemma-4-31b-it', + fallbacks: [], + size_overrides: [], + }, + }, + }); + + expect(selected).toEqual({ + primary: 'gemma-4-31b-it', + fallbacks: [], + }); + }); + it('rejects Cloudflare reasoning-only responses instead of trying to parse the response envelope', async () => { const env = createTestEnv({ AI: { diff --git a/test/resumable-queue.spec.ts b/test/resumable-queue.spec.ts index f3b65f3..c494817 100644 --- a/test/resumable-queue.spec.ts +++ b/test/resumable-queue.spec.ts @@ -1,6 +1,6 @@ import worker from '@server/index'; -import { claimJobLease, getJobForProcessing, insertJob, recoverExpiredJobLeases } from '@server/db/jobs'; -import { upsertFileReview, getFileReviewsForJobs } from '@server/db/file-reviews'; +import { claimJobLease, getJobForProcessing, insertJob, markJobContinuationQueued, recoverExpiredJobLeases, releaseJobLease } from '@server/db/jobs'; +import { getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; import { getDb } from '@server/db/client'; import { createTestEnv, hasConfiguredTestDatabaseUrl } from './helpers'; @@ -146,6 +146,44 @@ dbDescribe('resumable queue primitives', () => { expect(row?.error_msg).toBeNull(); }); + it('does not recover an unleased job that just scheduled a retry continuation', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `retry-handoff-${Date.now()}`, + prNumber: 1, + prTitle: 'Retry Handoff Test', + prAuthor: 'author', + commitSha: sha('7'), + baseSha: sha('8'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + await claimJobLease(env, job.id, 'lease-a', 600); + await getDb(env).query( + ` + UPDATE jobs + SET heartbeat_at = now() - interval '10 minutes', + last_queue_message_at = now() - interval '10 minutes' + WHERE id = $1 + `, + [job.id], + ); + + await markJobContinuationQueued(env, job.id); + await releaseJobLease(env, job.id, 'lease-a'); + + const recovered = await recoverExpiredJobLeases(env, 3, 120); + expect(recovered.requeuedJobIds).not.toContain(job.id); + + const row = await getJobForProcessing(env, job.id); + expect(row?.status).toBe('running'); + expect(row?.lease_owner).toBeNull(); + expect(row?.recovery_count).toBe(0); + }); + it('upserts file reviews without duplicating the same file', async () => { const job = await insertJob(env, { installationId: '123', @@ -185,6 +223,57 @@ dbDescribe('resumable queue primitives', () => { expect(reviews).toHaveLength(1); expect(reviews[0].file_summary).toBe('updated'); }); + + it('tracks retryable file review failures and resets the count after success', async () => { + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo: `transient-file-${Date.now()}`, + prNumber: 1, + prTitle: 'Transient File Test', + prAuthor: 'author', + commitSha: sha('9'), + baseSha: sha('0'), + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + }); + + const failureInput = { + filePath: 'src/app.ts', + modelUsed: 'gemma-4-31b-it', + modelProvider: 'google', + diffLineCount: 1, + diffInput: 'diff', + durationMs: 1, + errorMessage: 'All configured review models failed; retrying later.', + }; + + await expect(recordRetryableFileReviewFailure(env, job.id, failureInput)).resolves.toBe(1); + await expect(recordRetryableFileReviewFailure(env, job.id, failureInput)).resolves.toBe(2); + + await upsertFileReview(env, job.id, { + filePath: 'src/app.ts', + fileStatus: 'done', + modelUsed: 'gemma-4-31b-it', + modelProvider: 'google', + diffLineCount: 1, + diffInput: 'diff', + rawAiOutput: '{}', + parsedComments: [], + inputTokens: 1, + outputTokens: 1, + durationMs: 1, + verdict: 'approve', + fileSummary: 'ok', + errorMessage: null, + }); + + const reviews = await getFileReviewsForJobs(env, [job.id]); + expect(reviews).toHaveLength(1); + expect(reviews[0].file_status).toBe('done'); + expect(reviews[0].transient_error_count).toBe(0); + }); }); describe('queue handler', () => { diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index 23ce4ea..f598f42 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -1,7 +1,8 @@ import { runReviewJob } from '@server/core/review'; -import { createTestEnv, generateMockDiff } from './helpers'; +import { createTestEnv, generateMockDiff, hasConfiguredTestDatabaseUrl } from './helpers'; import { vi } from 'vitest'; -import { findExistingJobForHead, getJobForProcessing, insertJob } from '@server/db/jobs'; +import { findExistingJobForHead, getJobForProcessing, insertJob, updateJobFileCount, updateJobStep } from '@server/db/jobs'; +import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; import { defaultRepoConfig } from '@shared/schema'; import { runWithDb } from '@server/db/client'; @@ -225,6 +226,87 @@ dbDescribe('Review Flow Lifecycle', () => { expect(finalJob?.status).toBe('done'); }, REVIEW_FLOW_TIMEOUT_MS); + it('does not inherit parent file reviews from models outside the current retry strategy', async () => { + const { ModelService } = await import('@server/services/model'); + const reviewSpy = vi.spyOn(ModelService.prototype, 'reviewFile'); + const repo = `test-repo-${Date.now()}-retry-model-filter`; + const sourceHeadSha = sha('8'); + const retryHeadSha = sha('9'); + const baseSha = sha('0'); + + const source = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo, + prNumber: 6, + prTitle: 'Retry Model Filter', + prAuthor: 'author', + commitSha: sourceHeadSha, + baseSha, + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + configSnapshot: { + ...defaultRepoConfig, + model: { + main: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], + size_overrides: [], + }, + }, + }); + + await upsertFileReview(env, source.id, { + filePath: 'src/app.ts', + fileStatus: 'done', + modelUsed: '@cf/zai-org/glm-4.7-flash', + modelProvider: 'cloudflare', + diffLineCount: 1, + diffInput: 'old diff', + rawAiOutput: '{}', + parsedComments: [], + inputTokens: 1, + outputTokens: 1, + durationMs: 1, + verdict: 'approve', + fileSummary: 'old', + errorMessage: null, + }); + + const retry = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo, + prNumber: 6, + prTitle: 'Retry Model Filter', + prAuthor: 'author', + commitSha: retryHeadSha, + baseSha, + trigger: 'retry', + headRef: 'feature', + baseRef: 'main', + configSnapshot: { + ...defaultRepoConfig, + model: { + main: 'gemma-4-31b-it', + fallbacks: ['gemma-4-26b-a4b-it'], + size_overrides: [], + }, + }, + retryOfJobId: source.id, + }); + + await runAndDrain({ + jobId: retry.id, + deliveryId: 'delivery-retry-model-filter', + }); + + expect(reviewSpy).toHaveBeenCalled(); + const reviews = await getFileReviewsForJobs(env, [retry.id]); + expect(reviews.find((review) => review.file_path === 'src/app.ts')?.model_used).toBe('test-model'); + reviewSpy.mockRestore(); + }, REVIEW_FLOW_TIMEOUT_MS); + it('resumes an existing queued duplicate job instead of stranding it', async () => { const repo = `test-repo-${Date.now()}-duplicate`; const headSha = sha('4'); @@ -266,4 +348,54 @@ dbDescribe('Review Flow Lifecycle', () => { const finalJob = await getJobForProcessing(env, existing.id); expect(finalJob?.status).toBe('done'); }, REVIEW_FLOW_TIMEOUT_MS); + + it('schedules a delayed continuation instead of spending queue retries on transient model failures', async () => { + const { ModelService } = await import('@server/services/model'); + const retryableError = Object.assign(new Error('Google API timed out after 45000ms'), { retryable: true }); + const reviewSpy = vi.spyOn(ModelService.prototype, 'reviewFile').mockRejectedValue(retryableError); + const repo = `test-repo-${Date.now()}-transient`; + const headSha = sha('6'); + const baseSha = sha('7'); + + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo, + prNumber: 5, + prTitle: 'Transient Test', + prAuthor: 'author', + commitSha: headSha, + baseSha, + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + configSnapshot: defaultRepoConfig, + }); + await updateJobFileCount(env, job.id, 1); + await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); + + await runWithDb(env, async () => { + (env.REVIEW_QUEUE as any).sent.length = 0; + const result = await runReviewJob(env, { + jobId: job.id, + deliveryId: 'delivery-transient', + phase: 'review', + }); + + expect(result).toEqual({ action: 'ack' }); + expect(reviewSpy).toHaveBeenCalled(); + expect((env.REVIEW_QUEUE as any).sent).toHaveLength(1); + expect((env.REVIEW_QUEUE as any).sent[0]).toMatchObject({ + jobId: job.id, + phase: 'review', + options: { delaySeconds: 60 }, + }); + }); + + const finalJob = await getJobForProcessing(env, job.id); + expect(finalJob?.status).toBe('running'); + expect(finalJob?.lease_owner).toBeNull(); + + reviewSpy.mockRestore(); + }, REVIEW_FLOW_TIMEOUT_MS); }); diff --git a/test/settings.spec.ts b/test/settings.spec.ts new file mode 100644 index 0000000..fbbe084 --- /dev/null +++ b/test/settings.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeGlobalConfig } from '@client/pages/settings'; + +describe('settings model strategy', () => { + it('preserves an explicit empty global fallback list', () => { + const config = normalizeGlobalConfig({ + main: 'gemma-4-31b-it', + fallbacks: [], + size_overrides: [ + { + max_lines: 300, + model: 'gemma-4-31b-it', + fallbacks: [], + }, + ], + }); + + expect(config.fallbacks).toEqual([]); + expect(config.size_overrides[0].fallbacks).toEqual([]); + }); +}); From e642bb6d340d5392dc49fd9dfacc0c97579d2e9d Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 22 May 2026 21:24:28 +0530 Subject: [PATCH 04/32] perf: optimize review processing with prompt compaction and error recovery --- .../features/job-detail/job-meta-cards.tsx | 14 +- src/client/components/ui/badge.tsx | 8 + src/server/core/diff.ts | 19 +- src/server/core/job-recovery.ts | 8 + src/server/core/review.ts | 18 +- src/server/db/jobs.ts | 6 +- src/server/index.ts | 6 +- src/server/models/cloudflare.ts | 61 ++++-- src/server/models/google.ts | 5 +- src/server/prompts/file-review.ts | 4 + src/server/routes/api/jobs.ts | 6 +- src/server/services/model.ts | 106 ++++++++-- test/diff.spec.ts | 25 ++- test/model-service.spec.ts | 181 +++++++++++++++++- test/review-flow.spec.ts | 78 ++++++++ 15 files changed, 487 insertions(+), 58 deletions(-) diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx index 56ae254..acdae17 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -9,6 +9,8 @@ interface JobMetaCardsProps { } export function JobMetaCards({ job }: JobMetaCardsProps) { + const isPartialReview = job.status === 'done' && job.errorMessage?.startsWith('Partial review:'); + return (
{/* Details */} @@ -75,10 +77,16 @@ export function JobMetaCards({ job }: JobMetaCardsProps) { {job.errorMessage && (
-

Error

-

{job.errorMessage}

+

+ {isPartialReview ? 'Partial review' : 'Error'} +

+

{job.errorMessage}

)} diff --git a/src/client/components/ui/badge.tsx b/src/client/components/ui/badge.tsx index 58a5bb3..dff9c95 100644 --- a/src/client/components/ui/badge.tsx +++ b/src/client/components/ui/badge.tsx @@ -58,6 +58,14 @@ function StatusBadge({ label, job }: { label: string; job?: JobSummary }) { return ; } + if (job && label === 'done' && job.errorMessage) { + return ( + + partial + + ); + } + return ( {label.replace(/_/g, ' ')} diff --git a/src/server/core/diff.ts b/src/server/core/diff.ts index 153706f..64a5641 100644 --- a/src/server/core/diff.ts +++ b/src/server/core/diff.ts @@ -255,12 +255,23 @@ export function truncateFileDiff(file: FileDiff, maxLines: number): FileDiff { const keptHunks: DiffHunk[] = []; for (const hunk of file.hunks) { - if (currentLines + hunk.lines.length > maxLines && keptHunks.length > 0) { + const remainingLines = maxLines - currentLines; + if (remainingLines <= 0) { break; } - keptHunks.push(hunk); - currentLines += hunk.lines.length; - if (currentLines > maxLines) break; + + if (hunk.lines.length <= remainingLines) { + keptHunks.push(hunk); + currentLines += hunk.lines.length; + continue; + } + + keptHunks.push({ + ...hunk, + lines: hunk.lines.slice(0, remainingLines), + }); + currentLines += remainingLines; + break; } return { diff --git a/src/server/core/job-recovery.ts b/src/server/core/job-recovery.ts index 92e0ae7..9b3a80a 100644 --- a/src/server/core/job-recovery.ts +++ b/src/server/core/job-recovery.ts @@ -51,3 +51,11 @@ export async function runOpportunisticJobMaintenance(env: AppBindings) { await recoverJobs(env); await completeTerminalCheckRuns(env); } + +export async function runBestEffortJobMaintenance(env: AppBindings) { + try { + await runOpportunisticJobMaintenance(env); + } catch (error) { + logger.error('Opportunistic job maintenance failed', error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/src/server/core/review.ts b/src/server/core/review.ts index bd2d163..57187fa 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -180,7 +180,7 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): const phase = resolved.phase; const tracker = new TokenTracker(); const github = new GitHubService(env, job.installationId, tracker); - const model = new ModelService(env, tracker); + const model = new ModelService(env, tracker, { jobId: job.id }); const formatter = new FormatterService(env.APP_URL); try { @@ -427,8 +427,7 @@ async function runReviewPhase( if (inherited) { if (!canInheritParentFileReview(config, inherited)) { logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model); - currentReviews.set(file.path, true as any); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); processedThisChunk += 1; await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); } else { @@ -455,8 +454,7 @@ async function runReviewPhase( await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); } } else { - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model); - currentReviews.set(file.path, true as any); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); processedThisChunk += 1; await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); } @@ -493,8 +491,10 @@ async function reviewAndPersistFile( config: RepoConfig, totalLineCount: number, model: ModelService, + previousReview?: { transient_error_count: number }, ) { const startedAt = Date.now(); + const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; try { const response = await model.reviewFile({ file, @@ -502,6 +502,7 @@ async function reviewAndPersistFile( prDescription: pr.body ?? null, config, totalLineCount, + compactPrompt, }); await upsertFileReview(env, job.id, { @@ -637,6 +638,7 @@ async function runFinalizePhase( } const hasFailures = fileSummaries.some((file) => file.verdict === 'failed'); + const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length; const verdictSummary = formatter.summarizeVerdict(reviewedComments, hasFailures); const summaryResponse = await model.generateSummary({ prTitle: pr.title ?? null, @@ -684,12 +686,15 @@ async function runFinalizePhase( status: 'completed', conclusion: hasFailures ? 'failure' : (verdictSummary.verdict === 'approve' ? 'success' : 'neutral'), title: hasFailures ? 'Review partially failed' : (verdictSummary.verdict === 'approve' ? 'LGTM' : 'Comments posted'), - summary: `${reviewedComments.length} inline comments across ${files.length} files.${hasFailures ? ' Some files failed to parse.' : ''}`, + summary: `${reviewedComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed after repeated provider outages.` : ''}`, }); } const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0); const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0); + const partialErrorMessage = hasFailures + ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed after repeated model/provider outages.` + : null; await completeJob(env, job.id, { verdict: verdictSummary.verdict, fileCount: files.length, @@ -699,6 +704,7 @@ async function runFinalizePhase( summaryMarkdown: formattedSummary, reviewId: review.id, summaryModel: summaryResponse.modelUsed, + errorMessage: partialErrorMessage, }); await updateJobStep(env, job.id, 'Completing', { status: 'done' }); logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index 52941bc..ffe3cbd 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -505,6 +505,7 @@ export async function completeJob( reviewId: number | null; summaryModel: string | null; overallConfidenceScore?: number | null; + errorMessage?: string | null; }, ) { await queryRows( @@ -525,7 +526,7 @@ export async function completeJob( review_id = $8, summary_model = $9, overall_confidence_score = $10, - error_msg = NULL + error_msg = $11 WHERE id = $1 `, [ @@ -538,7 +539,8 @@ export async function completeJob( input.summaryMarkdown, input.reviewId, input.summaryModel, - input.overallConfidenceScore ?? null + input.overallConfidenceScore ?? null, + input.errorMessage ?? null ], ); } diff --git a/src/server/index.ts b/src/server/index.ts index be5157a..17bfd70 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,7 @@ import type { AppBindings } from './env'; import { reviewJobMessageSchema } from '@shared/schema'; import { logger } from '@server/core/logger'; import { runWithDb } from '@server/db/client'; -import { runOpportunisticJobMaintenance } from '@server/core/job-recovery'; +import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; const app = createApp(); @@ -15,7 +15,7 @@ export default { async queue(batch: MessageBatch, env: AppBindings, _ctx: ExecutionContext) { return runWithDb(env, async () => { - await runOpportunisticJobMaintenance(env); + await runBestEffortJobMaintenance(env); for (const message of batch.messages) { const parseResult = reviewJobMessageSchema.safeParse(message.body); @@ -42,7 +42,7 @@ export default { } } - await runOpportunisticJobMaintenance(env); + await runBestEffortJobMaintenance(env); }); }, } satisfies ExportedHandler; diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 52b831d..4814f25 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -7,10 +7,34 @@ import type { ModelResponse } from './types'; const CLOUDFLARE_TIMEOUT_MS = 45_000; const CLOUDFLARE_MAX_RETRIES = 1; +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null; +} + function isText(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function getRecord(value: unknown, key: string): UnknownRecord | null { + if (!isRecord(value)) return null; + const child = value[key]; + return isRecord(child) ? child : null; +} + +function getText(value: unknown, key: string): string | null { + if (!isRecord(value)) return null; + const child = value[key]; + return isText(child) ? child.trim() : null; +} + +function getNumber(value: unknown, key: string) { + if (!isRecord(value)) return null; + const child = value[key]; + return typeof child === 'number' ? child : null; +} + function extractMessageContent(content: unknown): string | null { if (isText(content)) return content.trim(); @@ -18,7 +42,7 @@ function extractMessageContent(content: unknown): string | null { const text = content .map((part) => { if (isText(part)) return part; - if (part && typeof part === 'object' && isText((part as any).text)) return (part as any).text; + if (isRecord(part) && isText(part.text)) return part.text; return ''; }) .join('') @@ -29,26 +53,40 @@ function extractMessageContent(content: unknown): string | null { return null; } -function extractCloudflareText(result: any, model: string): string { +function extractCloudflareText(result: unknown, model: string): string { if (isText(result)) return result.trim(); - if (isText(result?.response)) return result.response.trim(); - if (isText(result?.result?.response)) return result.result.response.trim(); + const response = getText(result, 'response'); + if (response) return response; - const choice = result?.choices?.[0]; - const content = extractMessageContent(choice?.message?.content); + const nestedResult = getRecord(result, 'result'); + const nestedResponse = getText(nestedResult, 'response'); + if (nestedResponse) return nestedResponse; + + const choices = isRecord(result) && Array.isArray(result.choices) ? result.choices : null; + const choice = choices?.[0]; + const message = getRecord(choice, 'message'); + const content = extractMessageContent(message?.content); if (content) return content; - const finishReason = choice?.finish_reason ?? choice?.stop_reason; + const finishReason = isRecord(choice) ? choice.finish_reason ?? choice.stop_reason : null; if (finishReason) { throw new Error(`Cloudflare model ${model} returned no review content (finish_reason=${finishReason}).`); } - if (isText(choice?.message?.reasoning) || isText(choice?.message?.reasoning_content)) { + if (isText(message?.reasoning) || isText(message?.reasoning_content)) { throw new Error(`Cloudflare model ${model} returned reasoning without review content.`); } throw new Error(`Cloudflare model ${model} returned an empty response.`); } +function extractCloudflareUsage(result: unknown) { + const usage = getRecord(result, 'usage') ?? getRecord(getRecord(result, 'result'), 'usage'); + return { + inputTokens: getNumber(usage, 'prompt_tokens') ?? 0, + outputTokens: getNumber(usage, 'completion_tokens') ?? 0, + }; +} + export async function reviewWithCloudflare( env: Pick, model: string, @@ -56,7 +94,7 @@ export async function reviewWithCloudflare( tracker?: { incrementSubrequests(count?: number): void }, ): Promise { const maxRetries = CLOUDFLARE_MAX_RETRIES; - let lastError: any; + let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { let timer: ReturnType | undefined; @@ -90,11 +128,12 @@ export async function reviewWithCloudflare( logger.info(`AI model ${model} responded in ${durationMs}ms`); const rawText = extractCloudflareText(result, model); + const usage = extractCloudflareUsage(result); return { rawText, - inputTokens: result?.usage?.prompt_tokens ?? result?.result?.usage?.prompt_tokens ?? 0, - outputTokens: result?.usage?.completion_tokens ?? result?.result?.usage?.completion_tokens ?? 0, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, modelUsed: model, provider: 'cloudflare', }; diff --git a/src/server/models/google.ts b/src/server/models/google.ts index 53c20de..b742768 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -6,6 +6,7 @@ import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ const GOOGLE_TIMEOUT_MS = 45_000; const GOOGLE_MAX_RETRIES = 1; +const GOOGLE_MAX_OUTPUT_TOKENS = 3072; export async function reviewWithGoogle( env: Pick, @@ -17,7 +18,7 @@ export async function reviewWithGoogle( const startTime = Date.now(); const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${env.GEMINI_API_KEY}`; const maxRetries = GOOGLE_MAX_RETRIES; - let lastError: any; + let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { @@ -47,7 +48,7 @@ export async function reviewWithGoogle( ], generationConfig: { responseMimeType: 'application/json', - maxOutputTokens: 4096, + maxOutputTokens: GOOGLE_MAX_OUTPUT_TOKENS, }, }), }), diff --git a/src/server/prompts/file-review.ts b/src/server/prompts/file-review.ts index 9a2846f..b2f1b28 100644 --- a/src/server/prompts/file-review.ts +++ b/src/server/prompts/file-review.ts @@ -12,6 +12,8 @@ Your goal is to identify bugs, security vulnerabilities, performance bottlenecks 4. Output EXACTLY ONE JSON object matching the schema below. 5. Focus on identifying critical issues (P0-P2). Nits (P3) should be minimized. 6. For each finding, provide a clear 'title', a 'body' explaining the issue, and 'code_location' (line or line_range). +7. Return at most 10 findings. Keep each body under 160 words. +8. If there are no material issues, return an empty findings array and a short explanation. ### SCHEMA FORMAT: { @@ -58,6 +60,8 @@ export function buildFileReviewPrompts(input: { `File path: ${input.file.path}`, languageGuidelines, `Custom rules:\n${rules}`, + 'Review only the diff shown below. If the diff note says it was truncated, do not infer issues from omitted lines.', + 'Prioritize correctness, security, and production-impacting bugs. Avoid speculative style feedback.', '', `## Output JSON Schema (STRICTLY REQUIRED)`, `{ diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index 87174a2..88c2c2d 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -3,14 +3,14 @@ import { jobsQuerySchema } from '@shared/schema'; import type { AppEnv } from '@server/env'; import { bytesToHex, getJobDetail, getJobForProcessing, insertJob, listJobs, mapJob, supersedeOlderJobs } from '@server/db/jobs'; import { jsonError } from '@server/core/http'; -import { runOpportunisticJobMaintenance } from '@server/core/job-recovery'; +import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; import { loadRepoConfig } from '@server/core/config'; export function createJobsRouter() { const app = new Hono(); app.get('/', async (c) => { - await runOpportunisticJobMaintenance(c.env); + await runBestEffortJobMaintenance(c.env); const rawQuery = c.req.query(); const query = jobsQuerySchema.parse(rawQuery); @@ -20,7 +20,7 @@ export function createJobsRouter() { }); app.get('/:id', async (c) => { - await runOpportunisticJobMaintenance(c.env); + await runBestEffortJobMaintenance(c.env); const job = await getJobDetail(c.env, c.req.param('id')); if (!job) { diff --git a/src/server/services/model.ts b/src/server/services/model.ts index 663ca3e..649a744 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -4,6 +4,7 @@ import { reviewWithCloudflare } from '../models/cloudflare'; import { buildFileReviewPrompts } from '../prompts/file-review'; import { buildSummaryPrompt, SUMMARY_SYSTEM_PROMPT } from '../prompts/summary'; import { parseFileReviewResponse } from '../core/model-output'; +import { truncateFileDiff } from '../core/diff'; import type { RepoConfig } from '@shared/schema'; import type { TokenTracker } from '../core/token-tracker'; import type { ModelResponse } from '../models/types'; @@ -11,10 +12,13 @@ import { logger } from '../core/logger'; import { normalizeModelId } from '@shared/schema'; const DEFAULT_GOOGLE_FALLBACK = 'gemma-4-31b-it'; +const PROVIDER_UNAVAILABLE_TTL_SECONDS = 24 * 60 * 60; +const COMPACT_REVIEW_PROMPT_LINE_CAP = 400; const MODEL_ALIASES: Record = { 'gemma-4-31b': 'gemma-4-31b-it', 'gemma-4-26b': 'gemma-4-26b-a4b-it', }; +type ModelProvider = 'cloudflare'; export class RetryableModelError extends Error { readonly retryable = true; @@ -23,13 +27,17 @@ export class RetryableModelError extends Error { super(message); this.name = 'RetryableModelError'; if (cause !== undefined) { - (this as any).cause = cause; + Object.defineProperty(this, 'cause', { + value: cause, + writable: true, + configurable: true, + }); } } } export function isRetryableModelError(error: unknown) { - return Boolean(error && typeof error === 'object' && (error as any).retryable === true); + return Boolean(error && typeof error === 'object' && 'retryable' in error && error.retryable === true); } function isCloudflareModel(model: string) { @@ -78,7 +86,49 @@ function isTransientModelFailure(error: unknown) { } export class ModelService { - constructor(private env: AppBindings, private tracker?: TokenTracker) {} + constructor( + private env: AppBindings, + private tracker?: TokenTracker, + private options: { jobId?: string } = {}, + ) {} + + private providerUnavailableKey(provider: ModelProvider) { + return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${provider}` : null; + } + + private async isProviderUnavailable(provider: ModelProvider) { + const key = this.providerUnavailableKey(provider); + if (!key) return false; + + try { + return (await this.env.APP_KV.get(key)) !== null; + } catch (error) { + logger.warn(`Failed to read unavailable provider marker for ${provider}`, { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + private async markProviderUnavailable(provider: ModelProvider, reason: string) { + const key = this.providerUnavailableKey(provider); + if (!key) return; + + try { + await this.env.APP_KV.put( + key, + JSON.stringify({ + reason, + markedAt: new Date().toISOString(), + }), + { expirationTtl: PROVIDER_UNAVAILABLE_TTL_SECONDS }, + ); + } catch (error) { + logger.warn(`Failed to write unavailable provider marker for ${provider}`, { + error: error instanceof Error ? error.message : String(error), + }); + } + } private selectModel(params: { totalLineCount: number; @@ -136,9 +186,16 @@ export class ModelService { prDescription: string | null; config: RepoConfig; totalLineCount: number; + compactPrompt?: boolean; }) { + const configuredLineCap = params.config.review.max_diff_lines_per_file; + const modelLineCap = params.compactPrompt + ? Math.min(configuredLineCap, COMPACT_REVIEW_PROMPT_LINE_CAP) + : configuredLineCap; + const reviewFile = truncateFileDiff(params.file, modelLineCap); const { systemPrompt, userPrompt } = buildFileReviewPrompts({ ...params, + file: reviewFile, config: params.config.review, }); @@ -148,12 +205,12 @@ export class ModelService { }); const modelsToTry = [primary, ...fallbacks]; - let lastError: any; + let lastError: unknown; + let lastTransientError: unknown; let sawTransientFailure = false; - const unavailableProviders = new Set(); for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && unavailableProviders.has('cloudflare')) { - logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable`); + if (isCloudflareModel(currentModel) && await this.isProviderUnavailable('cloudflare')) { + logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } @@ -173,22 +230,26 @@ export class ModelService { ...response, parsed, userPrompt, + reviewedLineCount: reviewFile.lineCount, + wasPromptTruncated: reviewFile.isTruncated === true, }; - } catch (error: any) { + } catch (error) { lastError = error; if (isTransientModelFailure(error)) { sawTransientFailure = true; + lastTransientError = error; } attempts++; if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - unavailableProviders.add('cloudflare'); + await this.markProviderUnavailable('cloudflare', error instanceof Error ? error.message : String(error)); } const isRateLimit = isGoogleRateLimitError(error); const isRetryable = false; + const errorMessage = error instanceof Error ? error.message : String(error); logger.warn(`Model ${currentModel} failed for ${params.file.path} (attempt ${attempts}/${maxAttempts})`, { - error: error.message || error, + error: errorMessage, rateLimited: isRateLimit, willRetrySameModel: isRetryable, willTryFallback: !isRetryable && modelsToTry.indexOf(currentModel) < modelsToTry.length - 1 @@ -203,10 +264,11 @@ export class ModelService { } if (sawTransientFailure) { - const lastMessage = lastError instanceof Error ? lastError.message : String(lastError ?? 'Unknown model error'); + const retryCause = lastTransientError ?? lastError; + const lastMessage = retryCause instanceof Error ? retryCause.message : String(retryCause ?? 'Unknown model error'); throw new RetryableModelError( `All configured review models failed for ${params.file.path}; retrying later. Last error: ${lastMessage}`, - lastError, + retryCause, ); } @@ -222,12 +284,12 @@ export class ModelService { const { primary, fallbacks } = this.selectModel({ totalLineCount: 0, config: params.config }); const modelsToTry = [primary, ...fallbacks]; - let lastError: any; + let lastError: unknown; + let lastTransientError: unknown; let sawTransientFailure = false; - const unavailableProviders = new Set(); for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && unavailableProviders.has('cloudflare')) { - logger.warn(`Skipping Cloudflare summary model ${currentModel} because Cloudflare AI allocation is unavailable`); + if (isCloudflareModel(currentModel) && await this.isProviderUnavailable('cloudflare')) { + logger.warn(`Skipping Cloudflare summary model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } @@ -242,23 +304,25 @@ export class ModelService { } return response; - } catch (error: any) { + } catch (error) { lastError = error; if (isTransientModelFailure(error)) { sawTransientFailure = true; + lastTransientError = error; } if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - unavailableProviders.add('cloudflare'); + await this.markProviderUnavailable('cloudflare', error instanceof Error ? error.message : String(error)); } - logger.warn(`Summary model ${currentModel} failed`, { error: error.message || error }); + logger.warn(`Summary model ${currentModel} failed`, { error: error instanceof Error ? error.message : String(error) }); } } if (sawTransientFailure) { - const lastMessage = lastError instanceof Error ? lastError.message : String(lastError ?? 'Unknown model error'); + const retryCause = lastTransientError ?? lastError; + const lastMessage = retryCause instanceof Error ? retryCause.message : String(retryCause ?? 'Unknown model error'); throw new RetryableModelError( `All configured summary models failed; retrying later. Last error: ${lastMessage}`, - lastError, + retryCause, ); } diff --git a/test/diff.spec.ts b/test/diff.spec.ts index d02313f..71060e5 100644 --- a/test/diff.spec.ts +++ b/test/diff.spec.ts @@ -106,8 +106,29 @@ Binary files a/image.png and b/image.png differ const truncated = truncateFileDiff(largeFile, 60); expect(truncated.isTruncated).toBe(true); - expect(truncated.hunks).toHaveLength(1); // Second hunk exceeds limit - expect(truncated.lineCount).toBe(50); + expect(truncated.hunks).toHaveLength(2); + expect(truncated.hunks[1].lines).toHaveLength(10); + expect(truncated.lineCount).toBe(60); + }); + + it('slices a single oversized hunk to the line limit', () => { + const largeFile = { + path: 'large.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 500, + hunks: [ + { header: '@@ -1,500 +1,500 @@', lines: Array(500).fill({ kind: 'add', content: 'line', position: 1 }) }, + ], + } as any; + + const truncated = truncateFileDiff(largeFile, 300); + expect(truncated.isTruncated).toBe(true); + expect(truncated.hunks).toHaveLength(1); + expect(truncated.hunks[0].lines).toHaveLength(300); + expect(truncated.lineCount).toBe(300); }); }); diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index fa16c2d..dc5aed9 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; import { reviewWithCloudflare } from '@server/models/cloudflare'; import { createTestEnv } from './helpers'; import { defaultRepoConfig } from '@shared/schema'; describe('ModelService', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('routes legacy Kimi K2.5 ids to Kimi K2.6 for new Cloudflare requests', async () => { let requestedModel = ''; const env = createTestEnv({ @@ -146,4 +150,179 @@ describe('ModelService', () => { }), ).rejects.toSatisfy(isRetryableModelError); }); + + it('skips Cloudflare for the rest of a job after allocation is exhausted', async () => { + let cloudflareCalls = 0; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[]}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv({ + AI: { + async run() { + cloudflareCalls++; + throw new Error('Cloudflare daily free allocation exhausted (4006)'); + }, + } as any, + GEMINI_API_KEY: 'test-key', + }); + const service = new ModelService(env, undefined, { jobId: 'job-provider-skip' }); + const file = { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }; + const config = { + ...defaultRepoConfig, + model: { + main: '@cf/zai-org/glm-4.7-flash', + fallbacks: ['gemma-4-31b-it'], + size_overrides: [], + }, + }; + + await service.reviewFile({ + file, + prTitle: 'Test', + prDescription: null, + config, + totalLineCount: 1, + }); + await service.reviewFile({ + file: { ...file, path: 'src/other.ts' }, + prTitle: 'Test', + prDescription: null, + config, + totalLineCount: 1, + }); + + expect(cloudflareCalls).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('uses the configured Gemma prompt cap and output token budget on the first attempt', async () => { + let requestBody: any = null; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + requestBody = JSON.parse(String(init?.body)); + return 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({ GEMINI_API_KEY: 'test-key' }); + const service = new ModelService(env); + const largeFile = { + path: 'src/large.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 900, + hunks: [ + { + header: '@@ -1,900 +1,900 @@', + lines: Array.from({ length: 900 }, (_, index) => ({ + kind: 'add' as const, + content: `const value${index} = ${index};`, + newLineNumber: index + 1, + position: index + 1, + })), + }, + ], + }; + + const response = await service.reviewFile({ + file: largeFile, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemma-4-31b-it', + fallbacks: [], + size_overrides: [], + }, + }, + totalLineCount: 500, + }); + + const userPrompt = requestBody.contents[0].parts[0].text as string; + expect(fetchMock).toHaveBeenCalledOnce(); + expect(requestBody.generationConfig.maxOutputTokens).toBe(3072); + expect(userPrompt).toContain('[NOTE: This diff has been truncated from 900 lines to 800 lines for brevity.]'); + expect(userPrompt).toContain('const value799 = 799;'); + expect(userPrompt).not.toContain('const value800 = 800;'); + expect(response.reviewedLineCount).toBe(800); + expect(response.wasPromptTruncated).toBe(true); + }); + + it('uses a compact Gemma prompt only after a prior transient failure', async () => { + let requestBody: any = null; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + requestBody = JSON.parse(String(init?.body)); + return 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({ GEMINI_API_KEY: 'test-key' }); + const service = new ModelService(env); + const largeFile = { + path: 'src/large.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 900, + hunks: [ + { + header: '@@ -1,900 +1,900 @@', + lines: Array.from({ length: 900 }, (_, index) => ({ + kind: 'add' as const, + content: `const value${index} = ${index};`, + newLineNumber: index + 1, + position: index + 1, + })), + }, + ], + }; + + const response = await service.reviewFile({ + file: largeFile, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemma-4-31b-it', + fallbacks: [], + size_overrides: [], + }, + }, + totalLineCount: 900, + compactPrompt: true, + }); + + const userPrompt = requestBody.contents[0].parts[0].text as string; + expect(userPrompt).toContain('[NOTE: This diff has been truncated from 900 lines to 400 lines for brevity.]'); + expect(userPrompt).toContain('const value399 = 399;'); + expect(userPrompt).not.toContain('const value400 = 400;'); + expect(response.reviewedLineCount).toBe(400); + expect(response.wasPromptTruncated).toBe(true); + }); }); diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index f598f42..7da7c31 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -398,4 +398,82 @@ dbDescribe('Review Flow Lifecycle', () => { reviewSpy.mockRestore(); }, REVIEW_FLOW_TIMEOUT_MS); + + it('marks completed jobs with skipped files as partial reviews', async () => { + const { GitHubService } = await import('@server/services/github'); + const repo = `test-repo-${Date.now()}-partial`; + const headSha = sha('e'); + const baseSha = sha('f'); + const getDiffSpy = vi.spyOn(GitHubService.prototype, 'getPullRequestDiff').mockResolvedValue( + generateMockDiff([ + { path: 'src/app.ts', content: 'console.log(1);' }, + { path: 'src/failed.ts', content: 'console.log(2);' }, + ]), + ); + + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo, + prNumber: 7, + prTitle: 'Partial Test', + prAuthor: 'author', + commitSha: headSha, + baseSha, + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + configSnapshot: defaultRepoConfig, + }); + await updateJobFileCount(env, job.id, 2); + await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await upsertFileReview(env, job.id, { + filePath: 'src/app.ts', + fileStatus: 'done', + modelUsed: 'test-model', + modelProvider: 'test-provider', + diffLineCount: 1, + diffInput: 'diff', + rawAiOutput: '{}', + parsedComments: [], + inputTokens: 1, + outputTokens: 1, + durationMs: 1, + verdict: 'approve', + fileSummary: 'ok', + errorMessage: null, + }); + await upsertFileReview(env, job.id, { + filePath: 'src/failed.ts', + fileStatus: 'failed', + modelUsed: 'gemma-4-31b-it', + modelProvider: 'google', + diffLineCount: 1, + diffInput: '', + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: 1, + verdict: null, + fileSummary: null, + errorMessage: 'Review skipped after 3 repeated model provider outages.', + }); + + await runWithDb(env, async () => { + (env.REVIEW_QUEUE as any).sent.length = 0; + const result = await runReviewJob(env, { + jobId: job.id, + deliveryId: 'delivery-partial', + phase: 'finalize', + }); + expect(result).toEqual({ action: 'ack' }); + }); + + const finalJob = await getJobForProcessing(env, job.id); + expect(finalJob?.status).toBe('done'); + expect(finalJob?.error_msg).toContain('Partial review: 1 of 2 files'); + getDiffSpy.mockRestore(); + }, REVIEW_FLOW_TIMEOUT_MS); }); From d0c2ee9e1134a137a25282503db0a8943c0fba0d Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 23 May 2026 20:54:41 +0530 Subject: [PATCH 05/32] add: improve job id status ui --- src/client/app.css | 4 +- .../features/job-detail/job-findings-list.tsx | 1 - .../features/job-detail/job-meta-cards.tsx | 180 ++++++++++---- .../features/job-detail/job-progress.tsx | 87 +++++-- .../features/reviews/live-review-stepper.tsx | 79 ++---- src/client/pages/job-logs.tsx | 227 +++++++++++++++--- 6 files changed, 424 insertions(+), 154 deletions(-) diff --git a/src/client/app.css b/src/client/app.css index d0a91e8..bc7c2d3 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -599,14 +599,14 @@ &.pending { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); } &.running { - @apply bg-info shadow-[0_0_0_3px_color-mix(in_oklch,var(--info)_25%,transparent)] animate-[pulse-ring_1.5s_var(--ease-out-quart)_infinite]; + @apply bg-info; } &.done { @apply bg-success; } &.failed { @apply bg-danger; } } @utility pulsing-dot { - @apply w-[7px] h-[7px] rounded-full bg-info inline-block animate-[pulse-ring_1.5s_var(--ease-out-quart)_infinite]; + @apply w-[7px] h-[7px] rounded-full bg-info inline-block; } /* ───────────────────────────────────────────────────── 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 c9ff752..084eb1f 100644 --- a/src/client/components/features/job-detail/job-findings-list.tsx +++ b/src/client/components/features/job-detail/job-findings-list.tsx @@ -21,7 +21,6 @@ export function JobFindingsList({ job }: JobFindingsListProps) {

Findings

- {job.status === 'running' && }
View by diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx index acdae17..d793ac6 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -1,122 +1,212 @@ -import { ExternalLink } from 'lucide-react'; +import { ExternalLink, Check, Minus, X, ArrowRight } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Card, CardContent, CardHeader, CardTitle } from '@client/components/ui/card'; import { Badge, StatusBadge } from '@client/components/ui/badge'; -import type { JobDetail } from '@shared/schema'; +import type { JobDetail, JobStep } from '@shared/schema'; interface JobMetaCardsProps { job: JobDetail; } +function elapsedSec(step: JobStep): string | null { + if (step.finishedAt && step.startedAt) { + const ms = new Date(step.finishedAt).getTime() - new Date(step.startedAt).getTime(); + return `${(ms / 1000).toFixed(1)}s`; + } + return null; +} + +function StepRow({ step, index, total }: { step: JobStep; index: number; total: number }) { + const isRunning = step.status === 'running'; + const isDone = step.status === 'done'; + const isFailed = step.status === 'failed'; + const isPending = step.status === 'pending'; + const isLast = index === total - 1; + + const elapsed = elapsedSec(step); + + // Left accent bar color + const accentColor = isDone + ? 'bg-success' + : isRunning + ? 'bg-info' + : isFailed + ? 'bg-danger' + : 'bg-border'; + + // Icon + const iconEl = isDone ? ( + + ) : isFailed ? ( + + ) : isRunning ? ( + + ) : ( + + ); + + return ( +
0 ? 'pt-3' : ''} ${!isLast ? 'border-b border-border/30' : ''}`}> + {/* Left accent strip */} +
+
+
+ +
+
+ {/* Step name + icon */} +
+ {iconEl} + + {step.name} + +
+ + {/* Right side: status or time */} +
+ {isRunning && ( + + In progress + + )} + {elapsed && ( + + {elapsed} + + )} + {!elapsed && !isRunning && ( + + )} +
+
+
+
+ ); +} + export function JobMetaCards({ job }: JobMetaCardsProps) { const isPartialReview = job.status === 'done' && job.errorMessage?.startsWith('Partial review:'); + const steps = job.steps ?? []; return (
- {/* Details */} + + {/* ── Job details ── */} Job details - -
+ + + {/* Metadata grid */} +
{[ - { label: 'Status', value: }, - { label: 'Verdict', value: job.verdict ? : }, + { label: 'Status', value: }, + { label: 'Verdict', value: job.verdict + ? + : + }, { label: 'Trigger', value: {job.trigger} }, - { label: 'Tokens', value: {(job.totalInputTokens + job.totalOutputTokens).toLocaleString()} }, + { label: 'Tokens', value: + + {(job.totalInputTokens + job.totalOutputTokens).toLocaleString()} + + }, ].map(({ label, value }) => (
-
{label}
+
+ {label} +
{value}
))} + + {job.reviewId && (
-
Review
+
Review
- View on GitHub + GitHub
)} + {job.retryOfJobId && (
-
Retry of
+
Retry of
- + {job.retryOfJobId.slice(0, 8)}…
)} -
-
Created
-
{new Date(job.createdAt).toLocaleString()}
+ +
+
Created
+
{new Date(job.createdAt).toLocaleString()}
+ {/* Error / partial message */} {job.errorMessage && (
-

+

{isPartialReview ? 'Partial review' : 'Error'}

-

{job.errorMessage}

+

+ {job.errorMessage} +

)}
- {/* Steps */} + {/* ── Progress steps ── */} Progress steps - {(job.steps ?? []).length === 0 ? ( -

No detailed steps available yet.

+ {steps.length === 0 ? ( +

No steps recorded yet.

) : ( -
- {(job.steps ?? []).map((step, idx) => ( -
-
-
- {step.name} -
- - {step.status === 'running' - ? 'Processing…' - : step.finishedAt && step.startedAt - ? `${((new Date(step.finishedAt).getTime() - new Date(step.startedAt).getTime()) / 1000).toFixed(1)}s` - : '—'} - -
+
+ {steps.map((step, idx) => ( + ))}
)} diff --git a/src/client/components/features/job-detail/job-progress.tsx b/src/client/components/features/job-detail/job-progress.tsx index 5c8bcf6..116692c 100644 --- a/src/client/components/features/job-detail/job-progress.tsx +++ b/src/client/components/features/job-detail/job-progress.tsx @@ -1,3 +1,4 @@ +import { FileCode2, Hourglass } from 'lucide-react'; import type { JobDetail } from '@shared/schema'; interface JobProgressProps { @@ -7,23 +8,79 @@ interface JobProgressProps { export function JobProgress({ job }: JobProgressProps) { if (job.status !== 'running' && job.status !== 'queued') return null; - const finishedFilesCount = job.files.filter((f) => f.fileStatus === 'done').length; - const totalFilesCount = job.fileCount || 0; - const progressPercent = totalFilesCount > 0 ? Math.round((finishedFilesCount / totalFilesCount) * 100) : 0; + const finishedCount = job.files.filter(f => f.fileStatus === 'done' || f.fileStatus === 'skipped').length; + const total = job.fileCount || 0; + const pct = total > 0 ? Math.round((finishedCount / total) * 100) : 0; + const isQueued = job.status === 'queued'; + + const activeFile = job.files.find(f => f.fileStatus === 'pending'); + const activeFilePath = activeFile?.filePath ?? null; + + // Shorten file path for display: keep last 2 segments + const displayPath = activeFilePath + ? activeFilePath.split('/').slice(-2).join('/') + : null; + const prefixPath = activeFilePath && activeFilePath.includes('/') + ? activeFilePath.split('/').slice(0, -2).join('/') + '/' + : null; return ( -
-
- - {job.status === 'queued' ? 'Queued…' : 'Reviewing files…'} - - {finishedFilesCount} / {totalFilesCount} files -
-
-
+
+ {/* Subtle grid texture */} +
+ +
+ {/* Top row: label + count */} +
+
+ {isQueued + ? + : + } + + {isQueued ? 'Waiting in queue' : 'Reviewing files'} + +
+ + {isQueued ? '—' : `${finishedCount} / ${total}`} + +
+ + {/* Progress track */} +
+
+
+ + {/* Active file + percent */} + {!isQueued && ( +
+
+ {prefixPath && ( + {prefixPath} + )} + {displayPath + ? {displayPath} + : + } +
+ {pct}% +
+ )}
); diff --git a/src/client/components/features/reviews/live-review-stepper.tsx b/src/client/components/features/reviews/live-review-stepper.tsx index 3c5d7cf..8bb5ef4 100644 --- a/src/client/components/features/reviews/live-review-stepper.tsx +++ b/src/client/components/features/reviews/live-review-stepper.tsx @@ -8,84 +8,51 @@ interface LiveReviewStepperProps { export function LiveReviewStepper({ job, compact = true }: LiveReviewStepperProps) { const { status, steps = [] } = job; - // Define our 4 target steps - const stepperLabels = ['Queued', 'Scanning', 'Analyzing', 'Done']; - - // Determine state for each of our 4 steps: 'pending' | 'running' | 'done' | 'failed' - let stepStates: Array<'pending' | 'running' | 'done' | 'failed'> = ['pending', 'pending', 'pending', 'pending']; let activeLabel = ''; if (status === 'queued') { - stepStates = ['running', 'pending', 'pending', 'pending']; activeLabel = 'Queued'; } else if (status === 'done') { - stepStates = ['done', 'done', 'done', 'done']; activeLabel = 'Done'; } else if (status === 'failed') { - // Basic mapping for failure const failedStep = steps.find(s => s.status === 'failed'); - if (failedStep) { - if (['Initializing', 'Fetching Diff'].includes(failedStep.name)) { - stepStates = ['done', 'failed', 'pending', 'pending']; - activeLabel = 'Scanning Failed'; - } else if (['Reviewing Files', 'Generating Summary'].includes(failedStep.name)) { - stepStates = ['done', 'done', 'failed', 'pending']; - activeLabel = 'Analysis Failed'; - } else { - stepStates = ['done', 'done', 'done', 'failed']; - activeLabel = 'Finalizing Failed'; - } + if (failedStep && ['Initializing', 'Fetching Diff'].includes(failedStep.name)) { + activeLabel = 'Scan failed'; + } else if (failedStep && ['Reviewing Files', 'Generating Summary'].includes(failedStep.name)) { + activeLabel = 'Review failed'; } else { - stepStates = ['done', 'done', 'done', 'failed']; activeLabel = 'Failed'; } } else if (status === 'running') { const runningStep = steps.find(s => s.status === 'running'); - if (!runningStep || ['Initializing', 'Fetching Diff'].includes(runningStep.name)) { - stepStates = ['done', 'running', 'pending', 'pending']; activeLabel = 'Scanning'; - } else if (['Reviewing Files', 'Generating Summary'].includes(runningStep.name)) { - stepStates = ['done', 'done', 'running', 'pending']; - activeLabel = 'Analyzing'; + } else if (runningStep.name === 'Reviewing Files') { + activeLabel = 'Reviewing'; + } else if (runningStep.name === 'Generating Summary') { + activeLabel = 'Summarising'; } else if (runningStep.name === 'Completing') { - stepStates = ['done', 'done', 'done', 'running']; - activeLabel = 'Finalizing'; + activeLabel = 'Finishing'; } else { - stepStates = ['done', 'done', 'running', 'pending']; - activeLabel = 'Analyzing'; + activeLabel = 'Running'; } } else if (status === 'superseded') { - stepStates = ['done', 'done', 'done', 'done']; // Treat as finished but maybe different color? activeLabel = 'Superseded'; } + const styles: Record = { + running: 'bg-info/10 text-info border-info/20', + queued: 'bg-secondary text-muted-foreground border-border/60', + done: 'bg-success/10 text-success border-success/20', + failed: 'bg-danger/10 text-danger border-danger/20', + superseded: 'bg-secondary text-muted-foreground border-border/40', + }; + + const cls = styles[status] ?? styles.queued; + return ( -
-
- {stepStates.map((state, i) => ( -
- ))} -
- {!compact && ( - - {activeLabel} - - )} - {compact && status === 'running' && ( - - {activeLabel}… - - )} - {compact && status === 'queued' && ( - - Queued - - )} -
+ + {activeLabel} + ); } diff --git a/src/client/pages/job-logs.tsx b/src/client/pages/job-logs.tsx index 779a69f..2e3d071 100644 --- a/src/client/pages/job-logs.tsx +++ b/src/client/pages/job-logs.tsx @@ -1,9 +1,138 @@ import { useParams, Link } from 'react-router-dom'; -import { ChevronLeft } from 'lucide-react'; +import { + ChevronLeft, FileCode2, Clock, Cpu, Hash, + AlertCircle, CheckCircle2, SkipForward, Hourglass, + ChevronDown, +} from 'lucide-react'; import { useJobDetail } from '@client/hooks/use-job-detail'; import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; import { Alert } from '@client/components/ui/alert'; -import { PageHeader } from '@client/components/layout/page-header'; +import type { FileReviewRecord } from '@shared/schema'; + +function fmtMs(ms: number | null) { + if (ms === null) return null; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function fmtK(n: number | null) { + if (n === null) return null; + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} + +type FileStatus = FileReviewRecord['fileStatus']; + +const STATUS_META: Record = { + done: { Icon: CheckCircle2, iconCls: 'text-success', pill: 'bg-success/10 text-success border-success/20', label: 'Done' }, + skipped: { Icon: SkipForward, iconCls: 'text-muted-foreground', pill: 'bg-secondary text-muted-foreground border-border/50', label: 'Skipped' }, + failed: { Icon: AlertCircle, iconCls: 'text-danger', pill: 'bg-danger/10 text-danger border-danger/20', label: 'Failed' }, + pending: { Icon: Hourglass, iconCls: 'text-muted-foreground', pill: 'bg-secondary text-muted-foreground border-border/50', label: 'Pending' }, +}; + +function FileCard({ file }: { file: FileReviewRecord }) { + const meta = STATUS_META[file.fileStatus] ?? STATUS_META.pending; + const { Icon } = meta; + const duration = fmtMs(file.durationMs); + const inTok = fmtK(file.inputTokens); + const outTok = fmtK(file.outputTokens); + const modelShort = file.modelUsed?.split('/').pop() ?? null; + + return ( +
+ + + {/* Status icon */} + + + {/* File path */} + + {file.filePath} + + + {/* Meta chips — hidden on small screens */} +
+ {modelShort && ( + + {modelShort} + + )} + {duration && ( + + {duration} + + )} + {(inTok || outTok) && ( + + {inTok ?? '—'}↑ {outTok ?? '—'}↓ + + )} +
+ + {/* Status pill */} + + {meta.label} + + + {/* Chevron */} + +
+ + {/* Expanded content */} +
+ + {/* Mobile meta strip */} +
+ {modelShort && {modelShort}} + {duration && {duration}} + {inTok && {inTok}↑ {outTok ?? '—'}↓} +
+ + {/* File-level error */} + {file.fileStatus === 'failed' && file.errorMessage && ( +
+

+ Review error +

+

+ {file.errorMessage} +

+
+ )} + + {/* Two-column content */} +
+
+

+ Prompt / diff +

+
+              {file.diffInput ?? '— No prompt saved —'}
+            
+
+
+

+ Raw model output +

+
+              {file.rawAiOutput ?? '— No output saved —'}
+            
+
+
+
+
+ ); +} export function JobLogsPage() { const { id = '' } = useParams(); @@ -11,47 +140,75 @@ export function JobLogsPage() { if (!job) return ; + const counts = { + done: job.files.filter(f => f.fileStatus === 'done').length, + skipped: job.files.filter(f => f.fileStatus === 'skipped').length, + failed: job.files.filter(f => f.fileStatus === 'failed').length, + total: job.files.length, + }; + return (
-
- - Back to Job Details - -
- + {/* Back */} + + + Back to Job Details + - {error && {error}} + {/* Page header */} +
+
+

Raw Logs

+

Review logs

+

+ {job.owner}/{job.repo} · PR #{job.prNumber} · {job.commitSha.slice(0, 7)} +

+
-
- {job.files.length === 0 ? ( -
No files processed.
- ) : ( - job.files.map((file) => ( -
-

{file.filePath}

-
-
-

- Prompt / diff -

-
{file.diffInput ?? 'No prompt saved.'}
-
-
-

- Raw model output -

-
{file.rawAiOutput ?? 'No raw output saved.'}
-
+ {/* Summary counts */} + {counts.total > 0 && ( +
+ {[ + { label: 'Files', val: counts.total, cls: 'text-foreground' }, + { label: 'Reviewed', val: counts.done, cls: 'text-success' }, + { label: 'Skipped', val: counts.skipped, cls: 'text-muted-foreground' }, + { label: 'Failed', val: counts.failed, cls: counts.failed > 0 ? 'text-danger' : 'text-muted-foreground' }, + ].map(({ label, val, cls }) => ( +
+ {val} + {label}
-
- )) + ))} +
)}
+ + {error && {error}} + + {/* File list */} + {job.files.length === 0 ? ( +
+ +
+

No files processed yet

+ {(job.status === 'running' || job.status === 'queued') && ( +

+ Logs appear here once files are reviewed +

+ )} +
+
+ ) : ( +
+ {job.files.map(file => ( + + ))} +
+ )}
); } From fff9b050959ec2647afe0f37b5dbd13680611a63 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 23 May 2026 21:09:53 +0530 Subject: [PATCH 06/32] add: improve sonner and job status ui --- src/client/app.css | 196 +++++++++++++++++- .../dashboard/updates-email-prompt.tsx | 8 +- src/client/main.tsx | 19 ++ src/client/pages/repos.tsx | 35 ++-- src/client/pages/settings.tsx | 24 ++- 5 files changed, 250 insertions(+), 32 deletions(-) diff --git a/src/client/app.css b/src/client/app.css index bc7c2d3..27d2baa 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -55,7 +55,7 @@ --success: oklch(64% 0.24 115); --success-bg: oklch(98% 0.04 115); --success-border: oklch(85% 0.15 115); - --warning: oklch(82% 0.18 65); + --warning: oklch(56% 0.18 65); --warning-bg: oklch(98% 0.04 65); --warning-border: oklch(90% 0.12 65); --danger: oklch(62% 0.22 25); @@ -550,9 +550,9 @@ } @utility badge-warning { - background-color: #fffbeb; - color: #d97706; - border-color: #fcd34d; + background-color: var(--warning-bg); + color: var(--warning); + border-color: var(--warning-border); .dark & { background-color: var(--warning-bg); @@ -865,3 +865,191 @@ & a { @apply text-primary underline underline-offset-2; } & hr { @apply border-border my-[1.5em]; } } + +/* ───────────────────────────────────────────────────── + Sonner Toast — Premium overrides +───────────────────────────────────────────────────── */ + +/* ── Outer list / viewport ───────────────────────── */ +[data-sonner-toaster] { + --offset: 1.25rem !important; + --width: 22rem !important; + font-family: var(--font-sans) !important; +} + +/* ── Base toast shell ─────────────────────────────── */ +.codra-toast { + display: flex !important; + align-items: flex-start !important; + gap: 0.625rem !important; + padding: 0.75rem 0.875rem !important; + border-radius: 0.625rem !important; + border-width: 1px !important; + border-style: solid !important; + font-family: var(--font-sans) !important; + font-size: 0.8125rem !important; + line-height: 1.45 !important; + box-shadow: + 0 4px 16px oklch(0% 0 0 / 0.10), + 0 1px 4px oklch(0% 0 0 / 0.06), + inset 0 1px 0 oklch(100% 0 0 / 0.05) !important; + + /* 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 */ + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important; +} + +.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), + 0 1px 6px oklch(0% 0 0 / 0.3), + inset 0 1px 0 oklch(100% 0 0 / 0.04) !important; +} + +/* ── Title ────────────────────────────────────────── */ +.codra-toast-title { + font-size: 0.8125rem !important; + font-weight: 600 !important; + letter-spacing: 0.005em !important; + line-height: 1.35 !important; +} + +/* ── Description ──────────────────────────────────── */ +.codra-toast-description { + font-size: 0.74rem !important; + font-weight: 400 !important; + opacity: 0.72 !important; + margin-top: 0.15rem !important; + line-height: 1.5 !important; +} + +/* ── Icon wrapper ─────────────────────────────────── */ +.codra-toast-icon { + margin-top: 0.05rem !important; + flex-shrink: 0 !important; +} + +/* ── Close button ─────────────────────────────────── */ +.codra-toast-close { + top: 0.55rem !important; + right: 0.55rem !important; + width: 1.25rem !important; + height: 1.25rem !important; + border-radius: 0.3rem !important; + background: oklch(88% 0.006 115 / 0.6) !important; + border: 1px solid oklch(82% 0.008 115 / 0.8) !important; + color: oklch(40% 0.015 115) !important; + transition: background 150ms, opacity 150ms !important; +} + +.dark .codra-toast-close { + background: oklch(22% 0.018 115 / 0.7) !important; + border-color: oklch(30% 0.02 115 / 0.8) !important; + color: oklch(65% 0.012 115) !important; +} + +.codra-toast-close:hover { + background: oklch(82% 0.010 115) !important; + opacity: 1 !important; +} + +.dark .codra-toast-close:hover { + 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; +} + +/* spinner inherits accent color */ +.codra-toast-loader svg { + color: var(--primary) !important; +} + +/* ── 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; +} + +.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; +} + +/* ── 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; +} + +.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; +} diff --git a/src/client/components/features/dashboard/updates-email-prompt.tsx b/src/client/components/features/dashboard/updates-email-prompt.tsx index bbcff85..2f94167 100644 --- a/src/client/components/features/dashboard/updates-email-prompt.tsx +++ b/src/client/components/features/dashboard/updates-email-prompt.tsx @@ -35,12 +35,12 @@ export function UpdatesEmailPrompt() { try { const response = await api.subscribeUpdates(email); setStatus(response); - toast.success('Updates email saved', { - description: 'You will only get important Codra release and security notes.', + toast.success('You’re subscribed', { + description: 'We’ll only reach out for important releases and security notices.', }); } catch (error) { - toast.error('Could not save updates email', { - description: error instanceof Error ? error.message : 'Please try again.', + toast.error('Subscription failed', { + description: 'We couldn’t save your email. Please check it and try again.', }); } finally { setSubmitting(false); diff --git a/src/client/main.tsx b/src/client/main.tsx index 2415a19..95b7dbf 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -26,6 +26,25 @@ function ToasterWrapper() { position="bottom-right" richColors closeButton + gap={8} + toastOptions={{ + duration: 4000, + classNames: { + toast: 'codra-toast', + title: 'codra-toast-title', + description: 'codra-toast-description', + actionButton: 'codra-toast-action', + cancelButton: 'codra-toast-cancel', + closeButton: 'codra-toast-close', + icon: 'codra-toast-icon', + loader: 'codra-toast-loader', + success: 'codra-toast-success', + error: 'codra-toast-error', + warning: 'codra-toast-warning', + info: 'codra-toast-info', + loading: 'codra-toast-loading', + }, + }} /> ); } diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 0b70032..d45b9c8 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -238,7 +238,7 @@ function RepoModelModal({ if (!repo || !dirty) return; setSaving('apply'); setError(null); - const tid = toast.loading(`Saving model strategy for ${repo.owner}/${repo.repo}...`); + const tid = toast.loading('Applying model strategy…'); try { await api.updateRepoConfig(repo.owner, repo.repo, { model: { @@ -249,11 +249,11 @@ function RepoModelModal({ }); setInitialRoute(route); onModelApplied(repo, route); - toast.success('Model strategy saved', { id: tid }); + toast.success('Strategy saved', { id: tid, description: `${repo.owner}/${repo.repo} now uses a custom model chain.` }); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to save model strategy.'; setError(msg); - toast.error('Save failed', { id: tid, description: msg }); + toast.error('Could not save strategy', { id: tid, description: 'Your changes were not applied. Please try again.' }); } finally { setSaving(null); } @@ -263,7 +263,7 @@ function RepoModelModal({ if (!repo) return; setSaving('reset'); setError(null); - const tid = toast.loading(`Using global strategy for ${repo.owner}/${repo.repo}...`); + const tid = toast.loading('Resetting to global defaults…'); try { await api.updateRepoConfig(repo.owner, repo.repo, { model: { @@ -276,11 +276,11 @@ function RepoModelModal({ setRoute(globalRoute); setInitialRoute(globalRoute); onModelReset(repo); - toast.success('Repo now uses global strategy', { id: tid }); + toast.success('Reset to global strategy', { id: tid, description: `${repo.owner}/${repo.repo} will inherit account defaults.` }); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to reset model strategy.'; setError(msg); - toast.error('Reset failed', { id: tid, description: msg }); + toast.error('Reset failed', { id: tid, description: 'Could not remove the custom strategy. Try again.' }); } finally { setSaving(null); } @@ -379,14 +379,19 @@ export function ReposPage() { const handleToggleEnabled = async (repo: RepoConfigRecord, nextEnabled: boolean) => { const targetId = repoId(repo); setPendingToggles(current => new Set(current).add(targetId)); - const tid = toast.loading(`${nextEnabled ? 'Enabling' : 'Pausing'} ${targetId}...`); + const tid = toast.loading(nextEnabled ? 'Enabling code reviews…' : 'Pausing code reviews…'); try { await api.updateRepoConfig(repo.owner, repo.repo, { enabled: nextEnabled }); mergeRepo(targetId, { enabled: nextEnabled }); - toast.success(nextEnabled ? 'Reviews enabled' : 'Reviews paused', { id: tid, description: targetId }); + toast.success( + nextEnabled ? 'Reviews active' : 'Reviews paused', + { id: tid, description: nextEnabled + ? `${targetId} will receive automated review comments.` + : `${targetId} is now quiet — no new reviews will be posted.` + }, + ); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to update repository.'; - toast.error('Update failed', { id: tid, description: msg }); + toast.error('Could not update repository', { id: tid, description: 'The change did not go through. Please try again.' }); } finally { setPendingToggles(current => { const next = new Set(current); @@ -416,19 +421,21 @@ export function ReposPage() { if (syncing) return; setSyncing(true); setError(null); - const tid = toast.loading('Syncing repositories from GitHub...'); + const tid = toast.loading('Syncing with GitHub…'); try { const result = await api.syncRepos(); const syncedCount = result?.synced?.length ?? 0; - toast.success('Repositories synced', { + toast.success('Repositories up to date', { id: tid, - description: `${syncedCount} ${syncedCount === 1 ? 'repository' : 'repositories'} synced successfully`, + description: syncedCount > 0 + ? `${syncedCount} ${syncedCount === 1 ? 'repository' : 'repositories'} refreshed from GitHub.` + : 'Everything is already in sync.', }); loadRepos(); } catch (e) { const msg = e instanceof Error ? e.message : 'Sync failed.'; setError(msg); - toast.error('Sync failed', { id: tid, description: msg }); + toast.error('Sync failed', { id: tid, description: 'Could not reach GitHub. Check your connection and try again.' }); } finally { setSyncing(false); } diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index cc75810..f09f86e 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -98,7 +98,7 @@ export function SettingsPage() { } catch (e) { const msg = e instanceof Error ? e.message : 'Failed to load settings'; setError(msg); - toast.error('Failed to load settings', { description: msg }); + toast.error('Could not load settings', { description: 'Something went wrong fetching your configuration.' }); } finally { setLoading(false); } @@ -110,18 +110,18 @@ export function SettingsPage() { if (!globalConfig || !globalDirty) return; setSaving('global'); setError(null); - const tid = toast.loading('Saving global strategy...'); + const tid = toast.loading('Saving model strategy…'); try { await api.updateGlobalConfig(globalConfig); setSavedGlobalConfig(globalConfig); toast.success('Global strategy saved', { id: tid, - description: `Primary model: ${getModelLabel(globalConfig.main)}`, + description: 'All repositories without a custom strategy will use these settings.', }); } catch (e) { const msg = e instanceof Error ? e.message : 'Update failed'; setError(msg); - toast.error('Failed to save strategy', { id: tid, description: msg }); + toast.error('Could not save strategy', { id: tid, description: 'Your changes were not applied. Please try again.' }); } finally { setSaving(null); } @@ -137,16 +137,16 @@ export function SettingsPage() { if (!current) return; setSaving(id); setError(null); - const tid = toast.loading(`Updating ${id}...`); + const tid = toast.loading('Updating quota…'); try { await api.updateModelConfig(id, quotaPayload(current)); const saved = { ...current, updatedAt: new Date().toISOString() }; markConfigSaved(id, saved); - toast.success('Model quota updated', { id: tid, description: id }); + toast.success('Quota updated', { id: tid, description: 'Rate limits have been applied to this model.' }); } catch (e) { const msg = e instanceof Error ? e.message : 'Update failed'; setError(msg); - toast.error('Failed to update quota', { id: tid, description: msg }); + toast.error('Quota update failed', { id: tid, description: 'The limit change did not save. Please try again.' }); } finally { setSaving(null); } @@ -156,7 +156,11 @@ export function SettingsPage() { if (dirtyConfigs.length === 0) return; setSaving('quotas'); setError(null); - const tid = toast.loading(`Saving ${dirtyConfigs.length} quota ${dirtyConfigs.length === 1 ? 'change' : 'changes'}...`); + const tid = toast.loading( + dirtyConfigs.length === 1 + ? 'Saving 1 quota change…' + : `Saving ${dirtyConfigs.length} quota changes…`, + ); try { await Promise.all(dirtyConfigs.map(cfg => api.updateModelConfig(cfg.modelId, quotaPayload(cfg)))); const now = new Date().toISOString(); @@ -165,11 +169,11 @@ export function SettingsPage() { setSavedConfigs(current => configs.map(cfg => (dirtyIds.has(cfg.modelId) ? { ...cfg, updatedAt: now } : current.find(saved => saved.modelId === cfg.modelId) ?? cfg)), ); - toast.success('Quotas saved', { id: tid }); + toast.success('All quotas saved', { id: tid, description: `${dirtyConfigs.length} ${dirtyConfigs.length === 1 ? 'model' : 'models'} updated successfully.` }); } catch (e) { const msg = e instanceof Error ? e.message : 'Update failed'; setError(msg); - toast.error('Failed to save quotas', { id: tid, description: msg }); + toast.error('Could not save quotas', { id: tid, description: 'One or more limits failed to save. Try again.' }); } finally { setSaving(null); } From 80de33b7bbc8a85fe5dccfa89a5226f13b9b386e Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 24 May 2026 20:49:16 +0530 Subject: [PATCH 07/32] refactor: improve model error handling and webhook routing --- src/server/app.ts | 3 +- src/server/core/job-recovery.ts | 10 +++++ src/server/core/review.ts | 41 ++++++++++++++------- src/server/models/cloudflare.ts | 4 +- src/server/models/google.ts | 4 +- src/server/routes/api/jobs.ts | 15 ++++++-- src/server/routes/webhook.ts | 13 ++++--- src/server/services/model.ts | 16 +++++++- test/model-service.spec.ts | 65 ++++++++++++++++++++++++++++++++- test/review-flow.spec.ts | 4 ++ test/webhook-handling.spec.ts | 32 ++++++++++++++++ 11 files changed, 176 insertions(+), 31 deletions(-) diff --git a/src/server/app.ts b/src/server/app.ts index 486cb53..ea766f0 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -5,7 +5,7 @@ import { requireSession } from '@server/middleware/auth'; import { requireCsrfHeader } from '@server/middleware/csrf'; import { observability } from '@server/middleware/observability'; import { createAuthRouter } from '@server/routes/auth'; -import { createWebhookRouter } from '@server/routes/webhook'; +import { createWebhookRouter, handleGitHubWebhook } from '@server/routes/webhook'; import { createAuthApiRouter } from '@server/routes/api/auth'; import { createJobsRouter } from '@server/routes/api/jobs'; import { createReposRouter } from '@server/routes/api/repos'; @@ -26,6 +26,7 @@ export function createApp() { app.route('/auth', createAuthRouter()); app.route('/webhook', createWebhookRouter()); + app.post('/', handleGitHubWebhook); app.use('/api/*', requireSession); app.use('/api/*', requireCsrfHeader); diff --git a/src/server/core/job-recovery.ts b/src/server/core/job-recovery.ts index 9b3a80a..c00c193 100644 --- a/src/server/core/job-recovery.ts +++ b/src/server/core/job-recovery.ts @@ -59,3 +59,13 @@ export async function runBestEffortJobMaintenance(env: AppBindings) { logger.error('Opportunistic job maintenance failed', error instanceof Error ? error : new Error(String(error))); } } + +export function scheduleBestEffortJobMaintenance( + env: AppBindings, + executionCtx?: Pick, +) { + const task = runBestEffortJobMaintenance(env); + if (executionCtx) { + executionCtx.waitUntil(task); + } +} diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 57187fa..9522b2f 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -22,7 +22,7 @@ const REVIEW_CHUNK_FILE_LIMIT = 2; const REVIEW_CHUNK_WALL_CLOCK_MS = 8 * 60 * 1000; const JOB_LEASE_SECONDS = 10 * 60; const BUSY_RETRY_SECONDS = 60; -const RETRYABLE_MODEL_FAILURE_RETRY_SECONDS = 60; +const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [60, 5 * 60, 15 * 60]; const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 3; function isRetryableFileReviewErrorMessage(message: string | null | undefined) { @@ -45,6 +45,21 @@ function isRetryableFileReviewErrorMessage(message: string | null | undefined) { ); } +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); + return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[index]; +} + +function getRetryableModelFailureDelaySeconds(error: unknown) { + const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null; + const retryAfterSeconds = + typeof record?.retryAfterSeconds === 'number' + ? record.retryAfterSeconds + : null; + return retryAfterSeconds ?? RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0]; +} + function shouldRetryExistingFileReview(review: { file_status: string; error_msg: string | null }) { return review.file_status === 'failed' && isRetryableFileReviewErrorMessage(review.error_msg); } @@ -187,7 +202,7 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): if (phase === 'prepare') { await runPreparePhase(env, job, leaseOwner, github); } else if (phase === 'finalize') { - await runFinalizePhase(env, job, leaseOwner, github, model, formatter); + await runFinalizePhase(env, job, leaseOwner, github, formatter); } else { await runReviewPhase(env, job, leaseOwner, github, model); } @@ -203,12 +218,13 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): } if (isRetryableModelError(error)) { + const delaySeconds = getRetryableModelFailureDelaySeconds(error); logger.warn(`Review job hit transient model/provider failure; scheduling delayed continuation: ${job.owner}/${job.repo} PR #${job.prNumber}`, { error: messageText, phase, - delaySeconds: RETRYABLE_MODEL_FAILURE_RETRY_SECONDS, + delaySeconds, }); - await enqueueJobPhase(env, job.id, phase, RETRYABLE_MODEL_FAILURE_RETRY_SECONDS); + await enqueueJobPhase(env, job.id, phase, delaySeconds); await releaseJobLease(env, job.id, leaseOwner); return { action: 'ack' }; } @@ -567,6 +583,10 @@ async function reviewAndPersistFile( error: errorMessage, attempts: failureCount, }); + Object.defineProperty(error, 'retryAfterSeconds', { + value: retryableModelFailureDelaySeconds(failureCount), + configurable: true, + }); throw error; } @@ -606,7 +626,6 @@ async function runFinalizePhase( job: PersistedReviewJob, leaseOwner: string, github: GitHubService, - model: ModelService, formatter: FormatterService, ) { await updateJobStep(env, job.id, 'Generating Summary', { status: 'running' }); @@ -640,12 +659,6 @@ async function runFinalizePhase( const hasFailures = fileSummaries.some((file) => file.verdict === 'failed'); const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length; const verdictSummary = formatter.summarizeVerdict(reviewedComments, hasFailures); - const summaryResponse = await model.generateSummary({ - prTitle: pr.title ?? null, - verdict: verdictSummary.verdict, - fileSummaries, - config, - }); await updateJobStep(env, job.id, 'Generating Summary', { status: 'done' }); await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); @@ -699,11 +712,11 @@ async function runFinalizePhase( verdict: verdictSummary.verdict, fileCount: files.length, commentCount: reviewedComments.length, - totalInputTokens: fileInputTokens + (summaryResponse.inputTokens ?? 0), - totalOutputTokens: fileOutputTokens + (summaryResponse.outputTokens ?? 0), + totalInputTokens: fileInputTokens, + totalOutputTokens: fileOutputTokens, summaryMarkdown: formattedSummary, reviewId: review.id, - summaryModel: summaryResponse.modelUsed, + summaryModel: null, errorMessage: partialErrorMessage, }); await updateJobStep(env, job.id, 'Completing', { status: 'done' }); diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 4814f25..a13440c 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -4,8 +4,8 @@ import { TimeoutError } from '@server/core/timeout'; import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Workers-AI call. */ -const CLOUDFLARE_TIMEOUT_MS = 45_000; -const CLOUDFLARE_MAX_RETRIES = 1; +const CLOUDFLARE_TIMEOUT_MS = 30_000; +const CLOUDFLARE_MAX_RETRIES = 0; type UnknownRecord = Record; diff --git a/src/server/models/google.ts b/src/server/models/google.ts index b742768..4931294 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -4,8 +4,8 @@ import { withTimeout } from '@server/core/timeout'; import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ -const GOOGLE_TIMEOUT_MS = 45_000; -const GOOGLE_MAX_RETRIES = 1; +const GOOGLE_TIMEOUT_MS = 30_000; +const GOOGLE_MAX_RETRIES = 0; const GOOGLE_MAX_OUTPUT_TOKENS = 3072; export async function reviewWithGoogle( diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index 88c2c2d..c494ad4 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -1,16 +1,25 @@ import { Hono } from 'hono'; +import type { Context } from 'hono'; import { jobsQuerySchema } from '@shared/schema'; import type { AppEnv } from '@server/env'; import { bytesToHex, getJobDetail, getJobForProcessing, insertJob, listJobs, mapJob, supersedeOlderJobs } from '@server/db/jobs'; import { jsonError } from '@server/core/http'; -import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; +import { scheduleBestEffortJobMaintenance } from '@server/core/job-recovery'; import { loadRepoConfig } from '@server/core/config'; +function getExecutionContext(c: Context) { + try { + return c.executionCtx; + } catch { + return undefined; + } +} + export function createJobsRouter() { const app = new Hono(); app.get('/', async (c) => { - await runBestEffortJobMaintenance(c.env); + scheduleBestEffortJobMaintenance(c.env, getExecutionContext(c)); const rawQuery = c.req.query(); const query = jobsQuerySchema.parse(rawQuery); @@ -20,7 +29,7 @@ export function createJobsRouter() { }); app.get('/:id', async (c) => { - await runBestEffortJobMaintenance(c.env); + scheduleBestEffortJobMaintenance(c.env, getExecutionContext(c)); const job = await getJobDetail(c.env, c.req.param('id')); if (!job) { diff --git a/src/server/routes/webhook.ts b/src/server/routes/webhook.ts index 3241b73..2fbaa1a 100644 --- a/src/server/routes/webhook.ts +++ b/src/server/routes/webhook.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import type { Context } from 'hono'; import { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload } from '@shared/github'; import type { AppEnv } from '@server/env'; import { loadRepoConfig } from '@server/core/config'; @@ -8,10 +9,7 @@ import { jsonError } from '@server/core/http'; import { findExistingJobForHead, insertJob, supersedeOlderJobs } from '@server/db/jobs'; import { recordWebhookDelivery } from '@server/db/webhook-deliveries'; -export function createWebhookRouter() { - const app = new Hono(); - - app.post('/', async (c) => { +export async function handleGitHubWebhook(c: Context) { const eventName = c.req.header('x-github-event'); const deliveryId = c.req.header('x-github-delivery'); const signature = c.req.header('x-hub-signature-256'); @@ -131,7 +129,12 @@ export function createWebhookRouter() { }); return c.json({ ok: true, message: 'queued' }, 202); - }); +} + +export function createWebhookRouter() { + const app = new Hono(); + + app.post('/', handleGitHubWebhook); return app; } diff --git a/src/server/services/model.ts b/src/server/services/model.ts index 649a744..f76aaf5 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -18,7 +18,7 @@ const MODEL_ALIASES: Record = { 'gemma-4-31b': 'gemma-4-31b-it', 'gemma-4-26b': 'gemma-4-26b-a4b-it', }; -type ModelProvider = 'cloudflare'; +type ModelProvider = 'cloudflare' | 'google'; export class RetryableModelError extends Error { readonly retryable = true; @@ -44,6 +44,10 @@ function isCloudflareModel(model: string) { return model.startsWith('@cf/'); } +function getModelProvider(model: string): ModelProvider { + return isCloudflareModel(model) ? 'cloudflare' : 'google'; +} + function normalizeModel(model: string) { return normalizeModelId(MODEL_ALIASES[model] ?? model); } @@ -208,8 +212,15 @@ export class ModelService { let lastError: unknown; let lastTransientError: unknown; let sawTransientFailure = false; + const transientlyFailedProviders = new Set(); for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && await this.isProviderUnavailable('cloudflare')) { + const provider = getModelProvider(currentModel); + if (transientlyFailedProviders.has(provider)) { + logger.warn(`Skipping ${provider} model ${currentModel} because another ${provider} model already hit a transient provider failure for ${params.file.path}`); + continue; + } + + if (provider === 'cloudflare' && await this.isProviderUnavailable('cloudflare')) { logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } @@ -238,6 +249,7 @@ export class ModelService { if (isTransientModelFailure(error)) { sawTransientFailure = true; lastTransientError = error; + transientlyFailedProviders.add(provider); } attempts++; if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index dc5aed9..7ea44eb 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -78,7 +78,7 @@ describe('ModelService', () => { ).rejects.toThrow('returned no review content'); }); - it('retries the same Cloudflare model once before failing it', async () => { + it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { let attempts = 0; const env = createTestEnv({ AI: { @@ -95,7 +95,68 @@ describe('ModelService', () => { userPrompt: 'user', }), ).rejects.toThrow('temporary provider error'); - expect(attempts).toBe(2); + expect(attempts).toBe(1); + }); + + it('skips later models from the same provider after a transient provider failure', async () => { + let cloudflareCalls = 0; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 500, + message: 'Internal error encountered.', + status: 'INTERNAL', + }, + }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv({ + AI: { + async run() { + cloudflareCalls++; + return { + response: JSON.stringify({ + findings: [], + overall_correctness: 'patch is correct', + overall_explanation: 'ok', + overall_confidence_score: 0.9, + }), + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + }, + } as any, + GEMINI_API_KEY: 'test-key', + }); + const service = new ModelService(env); + + 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', '@cf/zai-org/glm-4.7-flash'], + size_overrides: [], + }, + }, + totalLineCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cloudflareCalls).toBe(1); + expect(response.modelUsed).toBe('@cf/zai-org/glm-4.7-flash'); }); it('marks exhausted transient provider failures as retryable for the queue', async () => { diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index 7da7c31..bd574b0 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -401,6 +401,7 @@ dbDescribe('Review Flow Lifecycle', () => { it('marks completed jobs with skipped files as partial reviews', async () => { const { GitHubService } = await import('@server/services/github'); + const { ModelService } = await import('@server/services/model'); const repo = `test-repo-${Date.now()}-partial`; const headSha = sha('e'); const baseSha = sha('f'); @@ -425,6 +426,7 @@ dbDescribe('Review Flow Lifecycle', () => { baseRef: 'main', configSnapshot: defaultRepoConfig, }); + const summarySpy = vi.spyOn(ModelService.prototype as any, 'generateSummary'); await updateJobFileCount(env, job.id, 2); await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); @@ -474,6 +476,8 @@ dbDescribe('Review Flow Lifecycle', () => { const finalJob = await getJobForProcessing(env, job.id); expect(finalJob?.status).toBe('done'); expect(finalJob?.error_msg).toContain('Partial review: 1 of 2 files'); + expect(summarySpy).not.toHaveBeenCalled(); + summarySpy.mockRestore(); getDiffSpy.mockRestore(); }, REVIEW_FLOW_TIMEOUT_MS); }); diff --git a/test/webhook-handling.spec.ts b/test/webhook-handling.spec.ts index 66eb5ff..dcaa885 100644 --- a/test/webhook-handling.spec.ts +++ b/test/webhook-handling.spec.ts @@ -119,6 +119,38 @@ describe('Webhook Handling Suite', () => { expect(queue.sent[0].payload).toBeUndefined(); }); + it('also accepts GitHub webhooks posted to the site root', async () => { + const repoName = `root-repo-${Date.now()}`; + const rawPayload = createMockPRWebhook({ + action: 'opened', + repository: { name: repoName, owner: { login: 'test-owner' } } + }); + rawPayload.pull_request.head.sha = 'c'.repeat(40); + rawPayload.pull_request.base.sha = 'd'.repeat(40); + const body = JSON.stringify(rawPayload); + const signature = await signPayload(env.GITHUB_APP_WEBHOOK_SECRET, body); + + const response = await app.request( + 'http://codra.test/', + { + method: 'POST', + headers: { + 'x-github-event': 'pull_request', + 'x-github-delivery': `root-delivery-${Date.now()}`, + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }, + env, + ); + + const json = await response.json() as any; + expect(response.status).toBe(202); + expect(json.ok).toBe(true); + expect(json.message).toBe('queued'); + }); + it('acknowledges unsupported GitHub events without queueing review work', async () => { const rawPayload = createMockPRWebhook({ action: 'opened', From 92507ccf8bdcd78e3ad7d642601a54744cd447ac Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 24 May 2026 21:23:17 +0530 Subject: [PATCH 08/32] add: bump model duration for slow requests --- src/server/core/review.ts | 30 +++++++++----- src/server/models/cloudflare.ts | 5 ++- src/server/models/google.ts | 4 +- test/model-service.spec.ts | 2 +- test/review-flow.spec.ts | 73 +++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 9522b2f..5d00e82 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -433,6 +433,8 @@ async function runReviewPhase( const currentReviews = new Map(allExistingReviews.filter((review) => review.job_id === job.id).map((review) => [review.file_path, review])); const parentReviews = new Map(allExistingReviews.filter((review) => review.job_id !== job.id && review.file_status === 'done').map((review) => [review.file_path, review])); + const reviewTasks: Array> = []; + for (const file of files) { const existingReview = currentReviews.get(file.path); if (existingReview && countsAsHandledFileReview(existingReview)) { @@ -440,12 +442,15 @@ async function runReviewPhase( } const inherited = parentReviews.get(file.path); - if (inherited) { + const reviewTask = async () => { + if (!inherited) { + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); + return; + } + if (!canInheritParentFileReview(config, inherited)) { logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); - processedThisChunk += 1; - await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); } else { await upsertFileReview(env, job.id, { filePath: file.path, @@ -466,20 +471,25 @@ async function runReviewPhase( errorMessage: null, }); currentReviews.set(file.path, inherited); - processedThisChunk += 1; - await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); } - } else { - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); - processedThisChunk += 1; - await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); - } + }; + + reviewTasks.push(reviewTask()); + processedThisChunk += 1; if (processedThisChunk >= REVIEW_CHUNK_FILE_LIMIT || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { break; } } + const results = await Promise.allSettled(reviewTasks); + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + + const rejected = results.find((result) => result.status === 'rejected'); + if (rejected) { + throw rejected.reason; + } + const latestReviews = await getFileReviewsForJobs(env, [job.id]); const reviewedPaths = new Set(latestReviews.filter(countsAsHandledFileReview).map((review) => review.file_path)); const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length; diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index a13440c..0456f9a 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -4,8 +4,9 @@ import { TimeoutError } from '@server/core/timeout'; import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Workers-AI call. */ -const CLOUDFLARE_TIMEOUT_MS = 30_000; +const CLOUDFLARE_TIMEOUT_MS = 120_000; const CLOUDFLARE_MAX_RETRIES = 0; +const CLOUDFLARE_MAX_OUTPUT_TOKENS = 4096; type UnknownRecord = Record; @@ -119,7 +120,7 @@ export async function reviewWithCloudflare( { role: 'system', content: input.systemPrompt }, { role: 'user', content: input.userPrompt }, ], - max_completion_tokens: 4096, + max_completion_tokens: CLOUDFLARE_MAX_OUTPUT_TOKENS, temperature: 0, }), timeoutPromise, diff --git a/src/server/models/google.ts b/src/server/models/google.ts index 4931294..d85a60f 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -4,9 +4,9 @@ import { withTimeout } from '@server/core/timeout'; import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ -const GOOGLE_TIMEOUT_MS = 30_000; +const GOOGLE_TIMEOUT_MS = 120_000; const GOOGLE_MAX_RETRIES = 0; -const GOOGLE_MAX_OUTPUT_TOKENS = 3072; +const GOOGLE_MAX_OUTPUT_TOKENS = 4096; export async function reviewWithGoogle( env: Pick, diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 7ea44eb..3dd2c5e 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -321,7 +321,7 @@ describe('ModelService', () => { const userPrompt = requestBody.contents[0].parts[0].text as string; expect(fetchMock).toHaveBeenCalledOnce(); - expect(requestBody.generationConfig.maxOutputTokens).toBe(3072); + expect(requestBody.generationConfig.maxOutputTokens).toBe(4096); expect(userPrompt).toContain('[NOTE: This diff has been truncated from 900 lines to 800 lines for brevity.]'); expect(userPrompt).toContain('const value799 = 799;'); expect(userPrompt).not.toContain('const value800 = 800;'); diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index bd574b0..7a71f72 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -399,6 +399,79 @@ dbDescribe('Review Flow Lifecycle', () => { reviewSpy.mockRestore(); }, REVIEW_FLOW_TIMEOUT_MS); + it('reviews files in a chunk concurrently', async () => { + const { GitHubService } = await import('@server/services/github'); + const { ModelService } = await import('@server/services/model'); + const repo = `test-repo-${Date.now()}-concurrent`; + const headSha = sha('8'); + const baseSha = sha('9'); + const getDiffSpy = vi.spyOn(GitHubService.prototype, 'getPullRequestDiff').mockResolvedValue( + generateMockDiff([ + { path: 'src/one.ts', content: 'console.log(1);' }, + { path: 'src/two.ts', content: 'console.log(2);' }, + ]), + ); + let active = 0; + let maxActive = 0; + const reviewSpy = vi.spyOn(ModelService.prototype as any, 'reviewFile').mockImplementation(async (params: any) => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 25)); + active -= 1; + return { + parsed: { + comments: [], + verdict: 'approve', + fileSummary: `Reviewed ${params.file.path}`, + overallCorrectness: 'no issues', + confidenceScore: 0.9, + }, + modelUsed: 'test-model', + provider: 'test-provider', + inputTokens: 10, + outputTokens: 5, + rawText: '{}', + userPrompt: '', + }; + }); + + const job = await insertJob(env, { + installationId: '123', + owner: 'test-owner', + repo, + prNumber: 6, + prTitle: 'Concurrent Test', + prAuthor: 'author', + commitSha: headSha, + baseSha, + trigger: 'auto', + headRef: 'feature', + baseRef: 'main', + configSnapshot: defaultRepoConfig, + }); + await updateJobFileCount(env, job.id, 2); + await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); + + await runWithDb(env, async () => { + (env.REVIEW_QUEUE as any).sent.length = 0; + const result = await runReviewJob(env, { + jobId: job.id, + deliveryId: 'delivery-concurrent', + phase: 'review', + }); + + expect(result).toEqual({ action: 'ack' }); + expect(maxActive).toBe(2); + expect((env.REVIEW_QUEUE as any).sent[0]).toMatchObject({ jobId: job.id, phase: 'finalize' }); + }); + + const reviews = await getFileReviewsForJobs(env, [job.id]); + expect(reviews.filter((review) => review.file_status === 'done')).toHaveLength(2); + + reviewSpy.mockRestore(); + getDiffSpy.mockRestore(); + }, REVIEW_FLOW_TIMEOUT_MS); + it('marks completed jobs with skipped files as partial reviews', async () => { const { GitHubService } = await import('@server/services/github'); const { ModelService } = await import('@server/services/model'); From fb1ac181a6ec3565929086baea9a91599d7b0882 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 24 May 2026 22:21:09 +0530 Subject: [PATCH 09/32] fix: synthesize inconclusive reviews for cf reasoning-only responses and remove provider failure skipping --- package-lock.json | 772 +++++++++++++++++--------------- src/server/models/cloudflare.ts | 30 +- src/server/services/model.ts | 7 - src/server/worker-env.d.ts | 163 +++++-- test/model-service.spec.ts | 61 ++- 5 files changed, 591 insertions(+), 442 deletions(-) diff --git a/package-lock.json b/package-lock.json index c53d03b..a9e72f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,9 +58,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, @@ -416,9 +416,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -440,9 +440,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "dev": true, "funding": [ { @@ -457,7 +457,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -491,9 +491,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", "dev": true, "funding": [ { @@ -1012,9 +1012,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -1675,9 +1675,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -2496,9 +2496,9 @@ "license": "MIT" }, "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -2522,9 +2522,9 @@ } }, "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", "license": "MIT", "funding": { "type": "opencollective", @@ -2532,9 +2532,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -2549,9 +2549,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -2566,9 +2566,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -2583,9 +2583,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -2600,9 +2600,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -2617,9 +2617,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -2637,9 +2637,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -2657,9 +2657,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -2677,9 +2677,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -2697,9 +2697,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -2717,9 +2717,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -2737,9 +2737,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -2754,9 +2754,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -2773,9 +2773,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -2790,9 +2790,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -2807,9 +2807,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -2846,49 +2846,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -2903,9 +2903,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -2920,9 +2920,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -2937,9 +2937,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -2954,9 +2954,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -2971,9 +2971,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -2991,9 +2991,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -3011,9 +3011,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -3031,9 +3031,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -3051,9 +3051,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3069,10 +3069,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -3081,9 +3081,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -3098,9 +3098,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -3115,15 +3115,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -3219,9 +3219,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -3327,9 +3327,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -3366,13 +3366,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/picomatch": { @@ -3383,9 +3383,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3414,19 +3414,19 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -3446,15 +3446,15 @@ } }, "node_modules/@vitest/browser": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.5.tgz", - "integrity": "sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.7.tgz", + "integrity": "sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/mocker": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -3465,18 +3465,18 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.7" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.5.tgz", - "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.7.tgz", + "integrity": "sha512-OlTlJej7YN6VwV7zJJoNeaCsctF+JXpzpZ4oBHUbrQFfIq+0KW2f07rprCLh9N/zRIZ0v4Mchn1QDDmWMUhPKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.5", - "@vitest/mocker": "4.1.5", + "@vitest/browser": "4.1.7", + "@vitest/mocker": "4.1.7", "tinyrainbow": "^3.1.0" }, "funding": { @@ -3484,7 +3484,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.5" + "vitest": "4.1.7" }, "peerDependenciesMeta": { "playwright": { @@ -3493,16 +3493,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3511,13 +3511,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3538,9 +3538,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -3551,13 +3551,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -3565,14 +3565,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3581,9 +3581,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -3591,13 +3591,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4156,9 +4156,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "dev": true, "license": "MIT", "dependencies": { @@ -4558,9 +4558,9 @@ } }, "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "version": "4.12.22", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", + "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4708,9 +4708,9 @@ "license": "MIT" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -5068,9 +5068,9 @@ } }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5078,9 +5078,9 @@ } }, "node_modules/lucide-react": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", - "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6137,13 +6137,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -6156,9 +6156,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6179,9 +6179,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -6199,7 +6199,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6276,30 +6276,30 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT", "peer": true }, @@ -6331,9 +6331,9 @@ } }, "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -6401,9 +6401,9 @@ } }, "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6423,12 +6423,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", - "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", "license": "MIT", "dependencies": { - "react-router": "7.14.2" + "react-router": "7.15.1" }, "engines": { "node": ">=20.0.0" @@ -6641,14 +6641,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -6657,29 +6657,82 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/rosie-skills": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills/-/rosie-skills-0.6.4.tgz", + "integrity": "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==", "dev": true, - "license": "MIT" + "license": "BSD-3-Clause", + "bin": { + "rosie-skills": "dist/bin.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "rosie-skills-darwin-arm64": "0.6.4", + "rosie-skills-freebsd-x64": "0.6.4", + "rosie-skills-linux-x64": "0.6.4" + } + }, + "node_modules/rosie-skills-darwin-arm64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-darwin-arm64/-/rosie-skills-darwin-arm64-0.6.4.tgz", + "integrity": "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rosie-skills-freebsd-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-freebsd-x64/-/rosie-skills-freebsd-x64-0.6.4.tgz", + "integrity": "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rosie-skills-linux-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-linux-x64/-/rosie-skills-linux-x64-0.6.4.tgz", + "integrity": "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/rxjs": { "version": "7.8.2", @@ -6711,9 +6764,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -6950,9 +7003,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", "funding": { "type": "github", @@ -6960,9 +7013,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, @@ -6994,9 +7047,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", "dev": true, "license": "MIT", "engines": { @@ -7031,22 +7084,22 @@ } }, "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.1.1.tgz", + "integrity": "sha512-VuvOq9QVVdzQyIwynB0MRZlEup+u5BD62FjgmKvRDFO8u1RgAzpeg7Qd70hUmrxwkkecqoz1N6t1yGMygx7rnA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.30" + "tldts-core": "^7.1.1" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.1.1.tgz", + "integrity": "sha512-v9zYcyFEAJBeyG7g4+y/HFL9i2cHqpV+9cHohNZIhA6xjO2MSVgijFgx6quQaRBDzM5FT8fs5NPjsNITOhlCzg==", "dev": true, "license": "MIT" }, @@ -7147,9 +7200,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -7367,16 +7420,16 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -7393,7 +7446,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7460,19 +7513,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7500,12 +7553,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -7646,9 +7699,9 @@ } }, "node_modules/wrangler": { - "version": "4.87.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.87.0.tgz", - "integrity": "sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==", + "version": "4.94.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.94.0.tgz", + "integrity": "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -7656,10 +7709,11 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260430.0", + "miniflare": "4.20260521.0", "path-to-regexp": "6.3.0", + "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", - "workerd": "1.20260430.1" + "workerd": "1.20260521.1" }, "bin": { "wrangler": "bin/wrangler.js", @@ -7672,7 +7726,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260430.1" + "@cloudflare/workers-types": "^4.20260521.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -7681,9 +7735,9 @@ } }, "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz", - "integrity": "sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260521.1.tgz", + "integrity": "sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg==", "cpu": [ "x64" ], @@ -7698,9 +7752,9 @@ } }, "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz", - "integrity": "sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260521.1.tgz", + "integrity": "sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA==", "cpu": [ "arm64" ], @@ -7715,9 +7769,9 @@ } }, "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz", - "integrity": "sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260521.1.tgz", + "integrity": "sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw==", "cpu": [ "x64" ], @@ -7732,9 +7786,9 @@ } }, "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz", - "integrity": "sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260521.1.tgz", + "integrity": "sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA==", "cpu": [ "arm64" ], @@ -7749,9 +7803,9 @@ } }, "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz", - "integrity": "sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260521.1.tgz", + "integrity": "sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg==", "cpu": [ "x64" ], @@ -7766,17 +7820,17 @@ } }, "node_modules/wrangler/node_modules/miniflare": { - "version": "4.20260430.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260430.0.tgz", - "integrity": "sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==", + "version": "4.20260521.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260521.0.tgz", + "integrity": "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", - "workerd": "1.20260430.1", - "ws": "8.18.0", + "workerd": "1.20260521.1", + "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { @@ -7797,9 +7851,9 @@ } }, "node_modules/wrangler/node_modules/workerd": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260430.1.tgz", - "integrity": "sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260521.1.tgz", + "integrity": "sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -7810,17 +7864,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260430.1", - "@cloudflare/workerd-darwin-arm64": "1.20260430.1", - "@cloudflare/workerd-linux-64": "1.20260430.1", - "@cloudflare/workerd-linux-arm64": "1.20260430.1", - "@cloudflare/workerd-windows-64": "1.20260430.1" + "@cloudflare/workerd-darwin-64": "1.20260521.1", + "@cloudflare/workerd-darwin-arm64": "1.20260521.1", + "@cloudflare/workerd-linux-64": "1.20260521.1", + "@cloudflare/workerd-linux-arm64": "1.20260521.1", + "@cloudflare/workerd-windows-64": "1.20260521.1" } }, "node_modules/wrangler/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { @@ -7858,9 +7912,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -7961,9 +8015,9 @@ } }, "node_modules/zod": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", - "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 0456f9a..8939be9 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -36,6 +36,18 @@ function getNumber(value: unknown, key: string) { return typeof child === 'number' ? child : null; } +function synthesizeInconclusiveReview(model: string, reason: string): string { + logger.warn(`Cloudflare model ${model} returned no parseable review content; synthesizing inconclusive review JSON`, { + reason, + }); + return JSON.stringify({ + findings: [], + overall_correctness: 'patch is incorrect', + overall_explanation: `Cloudflare model ${model} returned no parseable review content (${reason}). The file review is inconclusive.`, + overall_confidence_score: 0, + }); +} + function extractMessageContent(content: unknown): string | null { if (isText(content)) return content.trim(); @@ -70,14 +82,22 @@ function extractCloudflareText(result: unknown, model: string): string { if (content) return content; const finishReason = isRecord(choice) ? choice.finish_reason ?? choice.stop_reason : null; - if (finishReason) { - throw new Error(`Cloudflare model ${model} returned no review content (finish_reason=${finishReason}).`); + const reasoning = isText(message?.reasoning) ? message.reasoning : isText(message?.reasoning_content) ? message.reasoning_content : null; + if (reasoning) { + if (reasoning.includes('{') && reasoning.includes('}')) { + logger.warn(`Cloudflare model ${model} returned reasoning without content; attempting to parse reasoning as review JSON`, { + finishReason, + }); + return reasoning.trim(); + } + return synthesizeInconclusiveReview(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); } - if (isText(message?.reasoning) || isText(message?.reasoning_content)) { - throw new Error(`Cloudflare model ${model} returned reasoning without review content.`); + + if (finishReason) { + return synthesizeInconclusiveReview(model, `finish_reason=${String(finishReason)}`); } - throw new Error(`Cloudflare model ${model} returned an empty response.`); + return synthesizeInconclusiveReview(model, 'empty response'); } function extractCloudflareUsage(result: unknown) { diff --git a/src/server/services/model.ts b/src/server/services/model.ts index f76aaf5..a06b604 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -212,14 +212,8 @@ export class ModelService { let lastError: unknown; let lastTransientError: unknown; let sawTransientFailure = false; - const transientlyFailedProviders = new Set(); for (const currentModel of modelsToTry) { const provider = getModelProvider(currentModel); - if (transientlyFailedProviders.has(provider)) { - logger.warn(`Skipping ${provider} model ${currentModel} because another ${provider} model already hit a transient provider failure for ${params.file.path}`); - continue; - } - if (provider === 'cloudflare' && await this.isProviderUnavailable('cloudflare')) { logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; @@ -249,7 +243,6 @@ export class ModelService { if (isTransientModelFailure(error)) { sawTransientFailure = true; lastTransientError = error; - transientlyFailedProviders.add(provider); } attempts++; if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { diff --git a/src/server/worker-env.d.ts b/src/server/worker-env.d.ts index e5014d6..664f4e6 100644 --- a/src/server/worker-env.d.ts +++ b/src/server/worker-env.d.ts @@ -1,34 +1,35 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: c82d96387e983e77c06e253e7ea4c4b1) -// Runtime types generated with workerd@1.20260430.1 2026-04-16 nodejs_compat +// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 2cf95a373086a6483897fb06140fae41) +// Runtime types generated with workerd@1.20260521.1 2026-04-16 nodejs_compat +interface __BaseEnv_Env { + APP_KV: KVNamespace; + HYPERDRIVE: Hyperdrive; + REVIEW_QUEUE: Queue; + AI: Ai; + ASSETS: Fetcher; + APP_URL: "https://app.codra.devarshi.dev"; + AUTH_CALLBACK_URL: "https://app.codra.devarshi.dev/auth/github/callback"; + BOT_USERNAME: "codra-app"; + GITHUB_APP_SLUG: "codra-app-personal"; + DASHBOARD_ALLOWED_USERS: "devarshishimpi"; + ENVIRONMENT: "production"; + CF_DLQ_ID: "ed6f5472cbd146f49ce94d3004eddb0f"; + APP_PRIVATE_KEY: string; + GITHUB_APP_ID: string; + GITHUB_APP_WEBHOOK_SECRET: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + GEMINI_API_KEY: string; + CF_API_TOKEN: string; + CF_ACCOUNT_ID: string; +} declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./index"); } - interface Env { - APP_KV: KVNamespace; - HYPERDRIVE: Hyperdrive; - REVIEW_QUEUE: Queue; - AI: Ai; - ASSETS: Fetcher; - APP_URL: "https://app.codra.devarshi.dev"; - AUTH_CALLBACK_URL: "https://app.codra.devarshi.dev/auth/github/callback"; - BOT_USERNAME: "codra-app"; - GITHUB_APP_SLUG: "codra-app-personal"; - DASHBOARD_ALLOWED_USERS: "devarshishimpi"; - ENVIRONMENT: "production"; - CF_DLQ_ID: "ed6f5472cbd146f49ce94d3004eddb0f"; - APP_PRIVATE_KEY: string; - GITHUB_APP_ID: string; - GITHUB_APP_WEBHOOK_SECRET: string; - GITHUB_CLIENT_ID: string; - GITHUB_CLIENT_SECRET: string; - GEMINI_API_KEY: string; - CF_API_TOKEN: string; - CF_ACCOUNT_ID: string; - } + interface Env extends __BaseEnv_Env {} } -interface Env extends Cloudflare.Env {} +interface Env extends __BaseEnv_Env {} type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; @@ -941,7 +942,7 @@ interface CustomEventCustomEventInit { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) */ declare class Blob { - constructor(type?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); + constructor(bits?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); /** * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes. * @@ -10155,12 +10156,17 @@ interface ArtifactsTokenListResult { /** Total number of tokens for the repository. */ total: number; } -/** Handle for a single repository. Returned by Artifacts.get(). */ +/** + * Handle for a single repository. Returned by Artifacts.get(). + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ interface ArtifactsRepo extends ArtifactsRepoInfo { /** * Create an access token for this repo. * @param scope Token scope: "write" (default) or "read". * @param ttl Time-to-live in seconds (default 86400, min 60, max 31536000). + * @throws {ArtifactsError} with code `INVALID_TTL` if ttl is out of range. */ createToken(scope?: 'write' | 'read', ttl?: number): Promise; /** List tokens for this repo (metadata only, no plaintext). */ @@ -10169,6 +10175,7 @@ interface ArtifactsRepo extends ArtifactsRepoInfo { * Revoke a token by plaintext or ID. * @param tokenOrId Plaintext token or token ID. * @returns true if revoked, false if not found. + * @throws {ArtifactsError} with code `INVALID_INPUT` if tokenOrId is empty. */ revokeToken(tokenOrId: string): Promise; // ── Fork ── @@ -10176,6 +10183,9 @@ interface ArtifactsRepo extends ArtifactsRepoInfo { * Fork this repo to a new repo. * @param name Target repository name. * @param opts Optional: description, readOnly flag, defaultBranchOnly (default true). + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if a fork is already running. */ fork(name: string, opts?: { description?: string; @@ -10183,13 +10193,41 @@ interface ArtifactsRepo extends ArtifactsRepoInfo { defaultBranchOnly?: boolean; }): Promise; } -/** Artifacts binding — namespace-level operations. */ +// ── Error types ────────────────────────────────────────────────────────────── +/** + * Error codes returned by Artifacts binding operations. + * + * Each code maps to a numeric code available on `ArtifactsError.numericCode`. + */ +type ArtifactsErrorCode = 'ALREADY_EXISTS' | 'NOT_FOUND' | 'IMPORT_IN_PROGRESS' | 'FORK_IN_PROGRESS' | 'INVALID_INPUT' | 'INVALID_REPO_NAME' | 'INVALID_TTL' | 'INVALID_URL' | 'REMOTE_AUTH_REQUIRED' | 'UPSTREAM_UNAVAILABLE' | 'MEMORY_LIMIT' | 'INTERNAL_ERROR'; +/** + * Error thrown by Artifacts binding operations. + * + * Uses a string `.code` discriminator following the Cloudflare platform + * convention (StreamError, ImagesError, etc.). The `.numericCode` matches + * the REST API `errors[].code` values. + */ +interface ArtifactsError extends Error { + readonly name: 'ArtifactsError'; + /** String error code for programmatic matching. */ + readonly code: ArtifactsErrorCode; + /** Numeric error code matching the REST API. */ + readonly numericCode: number; +} +// ── Binding ────────────────────────────────────────────────────────────────── +/** + * Artifacts binding — namespace-level operations. + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ interface Artifacts { /** * Create a new repository with an initial access token. * @param name Repository name (alphanumeric, dots, hyphens, underscores). * @param opts Optional: readOnly flag, description, default branch name. * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the repo already exists. */ create(name: string, opts?: { readOnly?: boolean; @@ -10200,12 +10238,23 @@ interface Artifacts { * Get a handle to an existing repository. * @param name Repository name. * @returns Repo handle. + * @throws {ArtifactsError} with code `NOT_FOUND` if the repo does not exist. + * @throws {ArtifactsError} with code `IMPORT_IN_PROGRESS` if the repo is still importing. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if the repo is still forking. */ get(name: string): Promise; /** * Import a repository from an external git remote. * @param params Source URL and optional branch/depth, plus target name and options. * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if the target name is invalid. + * @throws {ArtifactsError} with code `INVALID_INPUT` if the source URL is not valid HTTPS. + * @throws {ArtifactsError} with code `INVALID_URL` if the source URL does not point to a git repository. + * @throws {ArtifactsError} with code `REMOTE_AUTH_REQUIRED` if the remote requires authentication. + * @throws {ArtifactsError} with code `NOT_FOUND` if the remote repository does not exist. + * @throws {ArtifactsError} with code `UPSTREAM_UNAVAILABLE` if the remote cannot be reached. + * @throws {ArtifactsError} with code `MEMORY_LIMIT` if the import exceeds service memory limits. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. */ import(params: { source: { @@ -10233,6 +10282,7 @@ interface Artifacts { * Delete a repository and all associated tokens. * @param name Repository name. * @returns true if deleted, false if not found. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. */ delete(name: string): Promise; } @@ -11332,11 +11382,11 @@ interface SendEmail { send(message: EmailMessage): Promise; send(builder: { from: string | EmailAddress; - to: string | string[]; + to: string | EmailAddress | (string | EmailAddress)[]; subject: string; replyTo?: string | EmailAddress; - cc?: string | string[]; - bcc?: string | string[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; headers?: Record; text?: string; html?: string; @@ -12101,13 +12151,13 @@ declare namespace Cloudflare { type GlobalProp = K extends keyof GlobalProps ? GlobalProps[K] : Default; // The type of the program's main module exports, if known. Requires `GlobalProps` to declare the // `mainModule` property. - type MainModule = GlobalProp<'mainModule', {}>; + type MainModule = GlobalProp<"mainModule", {}>; // The type of ctx.exports, which contains loopback bindings for all top-level exports. type Exports = { - [K in keyof MainModule]: LoopbackForExport & + [K in keyof MainModule]: LoopbackForExport // If the export is listed in `durableNamespaces`, then it is also a // DurableObjectNamespace. - (K extends GlobalProp<'durableNamespaces', never> ? MainModule[K] extends new (...args: any[]) => infer DoInstance ? DoInstance extends Rpc.DurableObjectBranded ? DurableObjectNamespace : DurableObjectNamespace : DurableObjectNamespace : {}); + & (K extends GlobalProp<"durableNamespaces", never> ? MainModule[K] extends new (...args: any[]) => infer DoInstance ? DoInstance extends Rpc.DurableObjectBranded ? DurableObjectNamespace : DurableObjectNamespace : DurableObjectNamespace : {}); }; } declare namespace CloudflareWorkersModule { @@ -12178,24 +12228,15 @@ declare namespace CloudflareWorkersModule { attempt: number; config: WorkflowStepConfig; }; - export interface RollbackContext { - error: Error; - output: NonNullable | undefined; - stepName: string; - } - export interface StepPromise extends Promise { - rollback(fn: (ctx: RollbackContext) => Promise): StepPromise; - rollback(config: WorkflowStepConfig, fn: (ctx: RollbackContext) => Promise): StepPromise; - } export abstract class WorkflowStep { - do>(name: string, callback: (ctx: WorkflowStepContext) => Promise): StepPromise; - do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise): StepPromise; + do>(name: string, callback: (ctx: WorkflowStepContext) => Promise): Promise; + do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { type: string; timeout?: WorkflowTimeoutDuration | number; - }): StepPromise>; + }): Promise>; } export type WorkflowInstanceStatus = 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'waitingForPause' | 'unknown'; export abstract class WorkflowEntrypoint | unknown = unknown> implements Rpc.WorkflowEntrypointBranded { @@ -13179,6 +13220,9 @@ declare namespace TailStream { // 1. This is an Onset event // 2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) readonly spanId?: string; + // W3C trace flags from an upstream traceparent. Absent when no upstream + // sampling decision was made. + readonly traceFlags?: number; } interface TailEvent { // invocation id of the currently invoked worker stage. @@ -13553,6 +13597,27 @@ interface WorkflowError { code?: number; message: string; } +interface WorkflowInstanceRestartOptions { + /** + * Restart from a specific step. If omitted, the instance restarts from the beginning. + * The step must exist in the instance's execution history. + */ + from?: { + /** + * The step name as defined in your workflow code. + */ + name: string; + /** + * 1-indexed occurrence of this step name. Use when the same step name appears multiple times (e.g. in a loop). + * @default 1 + */ + count?: number; + /** + * Step type filter. Use when different step types share the same name. + */ + type?: 'do' | 'sleep' | 'waitForEvent'; + }; +} declare abstract class WorkflowInstance { public id: string; /** @@ -13568,9 +13633,11 @@ declare abstract class WorkflowInstance { */ public terminate(): Promise; /** - * Restart the instance. + * Restart the instance. Optionally restart from a specific step, preserving + * cached results for all steps before it. + * @param options Options for the restart, including an optional step to restart from. */ - public restart(): Promise; + public restart(options?: WorkflowInstanceRestartOptions): Promise; /** * Returns the current status of the instance. */ diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 3dd2c5e..6acb814 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -50,7 +50,7 @@ describe('ModelService', () => { }); }); - it('rejects Cloudflare reasoning-only responses instead of trying to parse the response envelope', async () => { + it('turns Cloudflare reasoning-only responses into inconclusive review JSON', async () => { const env = createTestEnv({ AI: { async run() { @@ -70,12 +70,15 @@ describe('ModelService', () => { } as any, }); - await expect( - reviewWithCloudflare(env, '@cf/moonshotai/kimi-k2.6', { - systemPrompt: 'system', - userPrompt: 'user', - }), - ).rejects.toThrow('returned no review content'); + const response = await reviewWithCloudflare(env, '@cf/moonshotai/kimi-k2.6', { + systemPrompt: 'system', + userPrompt: 'user', + }); + const parsed = JSON.parse(response.rawText); + + expect(parsed.findings).toEqual([]); + expect(parsed.overall_correctness).toBe('patch is incorrect'); + expect(parsed.overall_explanation).toContain('inconclusive'); }); it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { @@ -98,20 +101,30 @@ describe('ModelService', () => { expect(attempts).toBe(1); }); - it('skips later models from the same provider after a transient provider failure', async () => { + it('tries the smaller Google fallback after the primary Google model fails', async () => { let cloudflareCalls = 0; - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - JSON.stringify({ - error: { - code: 500, - message: 'Internal error encountered.', - status: 'INTERNAL', - }, - }), - { status: 500, headers: { 'content-type': 'application/json' } }, - ), - ); + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + code: 500, + message: 'Internal error encountered.', + status: 'INTERNAL', + }, + }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ), + ) + .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({ AI: { async run() { @@ -154,9 +167,11 @@ describe('ModelService', () => { totalLineCount: 1, }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(cloudflareCalls).toBe(1); - expect(response.modelUsed).toBe('@cf/zai-org/glm-4.7-flash'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemma-4-31b-it:generateContent'); + expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemma-4-26b-a4b-it:generateContent'); + expect(cloudflareCalls).toBe(0); + expect(response.modelUsed).toBe('gemma-4-26b-a4b-it'); }); it('marks exhausted transient provider failures as retryable for the queue', async () => { From 09ac6bf93e08919df2bf82a5d535807249913265 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 24 May 2026 22:36:57 +0530 Subject: [PATCH 10/32] add: improve error reporting and better date parsing and null safety checks --- src/client/app.css | 2 +- .../features/job-detail/job-meta-cards.tsx | 32 ++++++++++++------- .../features/job-detail/job-progress.tsx | 9 +++++- src/server/core/review.ts | 11 +++++-- test/review-flow.spec.ts | 5 +++ 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/client/app.css b/src/client/app.css index 27d2baa..a09fe98 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -873,7 +873,7 @@ /* ── Outer list / viewport ───────────────────────── */ [data-sonner-toaster] { --offset: 1.25rem !important; - --width: 22rem !important; + --width: min(22rem, calc(100vw - 2rem)) !important; font-family: var(--font-sans) !important; } diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx index d793ac6..0bc5915 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -10,7 +10,10 @@ interface JobMetaCardsProps { function elapsedSec(step: JobStep): string | null { if (step.finishedAt && step.startedAt) { - const ms = new Date(step.finishedAt).getTime() - new Date(step.startedAt).getTime(); + const start = new Date(step.startedAt).getTime(); + const end = new Date(step.finishedAt).getTime(); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + const ms = end - start; return `${(ms / 1000).toFixed(1)}s`; } return null; @@ -91,6 +94,7 @@ function StepRow({ step, index, total }: { step: JobStep; index: number; total: export function JobMetaCards({ job }: JobMetaCardsProps) { const isPartialReview = job.status === 'done' && job.errorMessage?.startsWith('Partial review:'); const steps = job.steps ?? []; + const shortCommitSha = job.commitSha?.slice(0, 7) ?? 'unknown'; return (
@@ -103,7 +107,7 @@ export function JobMetaCards({ job }: JobMetaCardsProps) { {/* Metadata grid */}
{[ - { label: 'Status', value: }, + { label: 'Status', value: }, { label: 'Verdict', value: job.verdict ? : @@ -126,15 +130,19 @@ export function JobMetaCards({ job }: JobMetaCardsProps) {
Commit
- - {job.commitSha.slice(0, 7)} - - + {job.commitSha ? ( + + {shortCommitSha} + + + ) : ( + {shortCommitSha} + )}
@@ -206,7 +214,7 @@ export function JobMetaCards({ job }: JobMetaCardsProps) { ) : (
{steps.map((step, idx) => ( - + ))}
)} diff --git a/src/client/components/features/job-detail/job-progress.tsx b/src/client/components/features/job-detail/job-progress.tsx index 116692c..fc794db 100644 --- a/src/client/components/features/job-detail/job-progress.tsx +++ b/src/client/components/features/job-detail/job-progress.tsx @@ -59,7 +59,14 @@ export function JobProgress({ job }: JobProgressProps) {
{/* Progress track */} -
+
result.status === 'rejected'); - if (rejected) { - throw rejected.reason; + 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); + }); + throw rejected.length === 1 + ? rejected[0].reason + : new AggregateError(rejected.map((result) => result.reason), `${rejected.length} review chunk tasks failed`); } const latestReviews = await getFileReviewsForJobs(env, [job.id]); diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index 7a71f72..b51bb85 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -63,7 +63,10 @@ vi.mock('@server/services/model', () => { async generateSummary() { return { modelUsed: 'sum-model', + provider: 'google', rawText: '{"summary": "test"}', + inputTokens: 3, + outputTokens: 2, }; } } @@ -549,6 +552,8 @@ dbDescribe('Review Flow Lifecycle', () => { const finalJob = await getJobForProcessing(env, job.id); expect(finalJob?.status).toBe('done'); expect(finalJob?.error_msg).toContain('Partial review: 1 of 2 files'); + expect(finalJob?.summary_markdown).toMatch(/^### Codra Review/); + expect(finalJob?.summary_model).toBeNull(); expect(summarySpy).not.toHaveBeenCalled(); summarySpy.mockRestore(); getDiffSpy.mockRestore(); From dfc637297badebb0269e8b0ea5151939e77cd57a Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 25 May 2026 00:40:42 +0530 Subject: [PATCH 11/32] add: optimize job polling with etag caching, adaptive delays, and structured model outputs --- src/client/hooks/use-job-detail.ts | 62 +++++++++++++++++++--------- src/client/lib/api.ts | 54 ++++++++++++++++++++++++- src/server/app.ts | 3 +- src/server/core/model-output.ts | 17 ++++---- src/server/core/review.ts | 6 +-- src/server/db/jobs.ts | 54 ++++++++++++++++++++++--- src/server/models/cloudflare.ts | 65 ++++++++++++++++++++++++++++-- src/server/models/google.ts | 2 +- src/server/routes/api/jobs.ts | 28 ++++++++++++- src/shared/schema.ts | 2 + test/model-service.spec.ts | 37 +++++++++++++++++ test/webhook-handling.spec.ts | 10 ++--- 12 files changed, 292 insertions(+), 48 deletions(-) diff --git a/src/client/hooks/use-job-detail.ts b/src/client/hooks/use-job-detail.ts index 0bd6f78..ba67efb 100644 --- a/src/client/hooks/use-job-detail.ts +++ b/src/client/hooks/use-job-detail.ts @@ -8,45 +8,71 @@ export function useJobDetail(id: string) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const [isRetrying, setIsRetrying] = useState(false); - const pollInterval = useRef(null); + const pollTimeout = useRef(null); + const etag = useRef(null); + const latestJob = useRef(null); + + const isTerminal = (candidate: JobDetail | null) => candidate?.status === 'done' || candidate?.status === 'failed' || candidate?.status === 'superseded'; + + const getPollDelay = (candidate: JobDetail | null) => { + if (!candidate || isTerminal(candidate)) return null; + + const nextRetryAt = candidate.nextRetryAt ? new Date(candidate.nextRetryAt).getTime() : null; + const waitingForRetry = nextRetryAt !== null && Number.isFinite(nextRetryAt) && nextRetryAt > Date.now(); + const baseDelay = waitingForRetry ? Math.min(Math.max(nextRetryAt - Date.now(), 10_000), 15_000) : 3_000; + + return document.visibilityState === 'hidden' ? Math.max(baseDelay, 45_000) : baseDelay; + }; const fetchJob = async (silent = false) => { try { - const response = await api.getJob(id); - setJob(response.job); + const response = await api.getJob(id, { etag: etag.current }); + if (response.etag) etag.current = response.etag; + if (!response.notModified && response.data) { + latestJob.current = response.data.job; + setJob(response.data.job); + } setError(null); - if (response.job.status === 'done' || response.job.status === 'failed') stopPolling(); + schedulePolling(); } catch (loadError) { if (!silent) setError(loadError instanceof Error ? loadError.message : 'Failed to load job.'); + schedulePolling(); } }; - const startPolling = () => { - if (pollInterval.current) return; - pollInterval.current = window.setInterval(() => fetchJob(true), 3000); - }; - const stopPolling = () => { - if (pollInterval.current) { - window.clearInterval(pollInterval.current); - pollInterval.current = null; + if (pollTimeout.current) { + window.clearTimeout(pollTimeout.current); + pollTimeout.current = null; } }; + const schedulePolling = () => { + stopPolling(); + const delay = getPollDelay(latestJob.current); + if (delay === null) return; + pollTimeout.current = window.setTimeout(() => fetchJob(true), delay); + }; + useEffect(() => { if (id) { + etag.current = null; + latestJob.current = null; fetchJob(); } return () => stopPolling(); }, [id]); useEffect(() => { - if (job && (job.status === 'queued' || job.status === 'running')) { - startPolling(); - } else { - stopPolling(); - } - }, [job?.status]); + latestJob.current = job; + schedulePolling(); + }, [job?.status, job?.nextRetryAt]); + + useEffect(() => { + const reschedule = () => schedulePolling(); + document.addEventListener('visibilitychange', reschedule); + return () => document.removeEventListener('visibilitychange', reschedule); + }, [id, job?.status, job?.nextRetryAt]); const handleRetry = async () => { if (!job) return; diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 368aeeb..3a469d4 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -51,6 +51,52 @@ async function request(input: string, init?: RequestInit) { return (await response.json()) as T; } +async function requestWithMeta(input: string, init?: RequestInit) { + const method = init?.method?.toUpperCase() ?? 'GET'; + const headers = new Headers(init?.headers); + + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + + if (!SAFE_METHODS.has(method)) { + headers.set('x-requested-with', 'XMLHttpRequest'); + } + + const response = await fetch(input, { + credentials: 'same-origin', + ...init, + headers, + }); + + if (response.status === 401) { + if (location.pathname !== '/login') { + location.href = '/login'; + } + throw new Error('Unauthorized'); + } + + const etag = response.headers.get('etag'); + const lastModified = response.headers.get('last-modified'); + + if (response.status === 304) { + return { status: response.status, etag, lastModified, notModified: true as const }; + } + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error ?? `Request failed with ${response.status}`); + } + + return { + status: response.status, + etag, + lastModified, + notModified: false as const, + data: (await response.json()) as T, + }; +} + export const api = { getSession() { return request('/api/auth/session'); @@ -79,8 +125,12 @@ export const api = { const query = searchParams.toString(); return request(`/api/jobs${query ? `?${query}` : ''}`); }, - getJob(id: string) { - return request(`/api/jobs/${id}`); + getJob(id: string, options: { etag?: string | null } = {}) { + const headers = new Headers(); + if (options.etag) { + headers.set('if-none-match', options.etag); + } + return requestWithMeta(`/api/jobs/${id}`, { headers }); }, retryJob(id: string) { return request(`/api/jobs/${id}/retry`, { diff --git a/src/server/app.ts b/src/server/app.ts index ea766f0..486cb53 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -5,7 +5,7 @@ import { requireSession } from '@server/middleware/auth'; import { requireCsrfHeader } from '@server/middleware/csrf'; import { observability } from '@server/middleware/observability'; import { createAuthRouter } from '@server/routes/auth'; -import { createWebhookRouter, handleGitHubWebhook } from '@server/routes/webhook'; +import { createWebhookRouter } from '@server/routes/webhook'; import { createAuthApiRouter } from '@server/routes/api/auth'; import { createJobsRouter } from '@server/routes/api/jobs'; import { createReposRouter } from '@server/routes/api/repos'; @@ -26,7 +26,6 @@ export function createApp() { app.route('/auth', createAuthRouter()); app.route('/webhook', createWebhookRouter()); - app.post('/', handleGitHubWebhook); app.use('/api/*', requireSession); app.use('/api/*', requireCsrfHeader); diff --git a/src/server/core/model-output.ts b/src/server/core/model-output.ts index 856912e..3c14633 100644 --- a/src/server/core/model-output.ts +++ b/src/server/core/model-output.ts @@ -5,11 +5,11 @@ import { findClosestValidLine, findPositionForLine, getValidNewLines, getValidPo import type { FileDiff } from './diff'; import { jsonrepair } from 'jsonrepair'; -const MAX_LOGGED_MODEL_OUTPUT_CHARS = 2_000; +const MAX_LOGGED_JSON_CHARS = 2_000; -function truncateForLog(value: string) { - if (value.length <= MAX_LOGGED_MODEL_OUTPUT_CHARS) return value; - return `${value.slice(0, MAX_LOGGED_MODEL_OUTPUT_CHARS)}... [truncated ${value.length - MAX_LOGGED_MODEL_OUTPUT_CHARS} chars]`; +function truncateJsonForLog(value: string) { + if (value.length <= MAX_LOGGED_JSON_CHARS) return value; + return `${value.slice(0, MAX_LOGGED_JSON_CHARS)}... [truncated ${value.length - MAX_LOGGED_JSON_CHARS} chars]`; } function hasReviewKeys(input: string) { @@ -260,7 +260,10 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): { throw new Error('Model response did not contain review JSON keys.'); } } catch (e) { - logger.error('Failed to extract JSON from model response', { raw: truncateForLog(raw), error: e }); + logger.error('Failed to extract JSON from model response', { + rawLength: raw.length, + error: e instanceof Error ? e.message : String(e), + }); throw new Error('Could not find JSON root in model response.'); } @@ -276,14 +279,14 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): { try { repaired = jsonrepair(preprocessed); } catch (e) { - logger.warn('jsonrepair failed to fix model output, using preprocessed text', { preprocessed: truncateForLog(preprocessed), error: e }); + logger.warn('jsonrepair failed to fix model output, using preprocessed text', { preprocessed: truncateJsonForLog(preprocessed), error: e }); } let parsedJson: any; try { parsedJson = JSON.parse(repaired); } catch (e) { - logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateForLog(repaired), error: e }); + logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateJsonForLog(repaired), error: e }); throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`); } diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 6ef7c07..167492f 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -19,8 +19,8 @@ type PersistedReviewJob = ReturnType; export type ReviewJobRunResult = { action: 'ack' } | { action: 'retry'; delaySeconds: number }; const REVIEW_CHUNK_FILE_LIMIT = 2; -const REVIEW_CHUNK_WALL_CLOCK_MS = 8 * 60 * 1000; -const JOB_LEASE_SECONDS = 10 * 60; +const REVIEW_CHUNK_WALL_CLOCK_MS = 12 * 60 * 1000; +const JOB_LEASE_SECONDS = 15 * 60; const BUSY_RETRY_SECONDS = 60; const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [60, 5 * 60, 15 * 60]; const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 3; @@ -752,7 +752,7 @@ async function enqueueJobPhase( phase: 'prepare' | 'review' | 'finalize', delaySeconds = 0, ) { - await markJobContinuationQueued(env, jobId); + await markJobContinuationQueued(env, jobId, delaySeconds); await env.REVIEW_QUEUE.send( { jobId, diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index ffe3cbd..b97b83a 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -82,7 +82,34 @@ export function bytesToHex(value: ByteaValue) { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); } +function latestTimestamp(...values: Array) { + const now = Date.now(); + return values.reduce((latest, value) => { + if (!value) return latest; + if (new Date(value).getTime() > now) return latest; + if (!latest) return value; + return new Date(value).getTime() > new Date(latest).getTime() ? value : latest; + }, null); +} + export function mapJob(row: JobRow) { + const lastQueueMessageAt = row.last_queue_message_at ? new Date(row.last_queue_message_at).getTime() : null; + const nextRetryAt = + row.status === 'running' && + row.lease_owner === null && + lastQueueMessageAt !== null && + Number.isFinite(lastQueueMessageAt) && + lastQueueMessageAt > Date.now() + ? row.last_queue_message_at + : null; + const updatedAt = latestTimestamp( + row.created_at, + row.started_at, + row.finished_at, + row.heartbeat_at, + row.last_queue_message_at, + ) ?? row.created_at; + return jobSummarySchema.parse({ id: row.id, owner: row.owner, @@ -100,6 +127,8 @@ export function mapJob(row: JobRow) { totalInputTokens: row.total_input_tokens ?? 0, totalOutputTokens: row.total_output_tokens ?? 0, createdAt: row.created_at, + updatedAt, + nextRetryAt, startedAt: row.started_at, finishedAt: row.finished_at, errorMessage: row.error_msg, @@ -402,6 +431,12 @@ export async function claimJobLease( OR lease_expires_at < now() OR lease_owner = $2 ) + AND NOT ( + status = 'running' + AND lease_owner IS NULL + AND last_queue_message_at IS NOT NULL + AND last_queue_message_at > now() + ) RETURNING * ) SELECT c.*, r.owner, r.repo, r.installation_id @@ -424,8 +459,10 @@ export async function claimJobLease( return { status: 'terminal', row }; } - const expiresAt = row.lease_expires_at ? new Date(row.lease_expires_at).getTime() : 0; - const secondsUntilExpiry = Math.ceil((expiresAt - Date.now()) / 1000); + const leaseExpiresAt = row.lease_expires_at ? new Date(row.lease_expires_at).getTime() : 0; + const delayedUntil = row.lease_owner === null && row.last_queue_message_at ? new Date(row.last_queue_message_at).getTime() : 0; + const retryAt = Math.max(leaseExpiresAt, delayedUntil); + const secondsUntilExpiry = Math.ceil((retryAt - Date.now()) / 1000); return { status: 'busy', row, @@ -467,16 +504,20 @@ export async function releaseJobLease(env: Pick, jobI ); } -export async function markJobContinuationQueued(env: Pick, jobId: string) { +export async function markJobContinuationQueued(env: Pick, jobId: string, delaySeconds = 0) { await queryRows( env, ` UPDATE jobs - SET last_queue_message_at = now() + SET heartbeat_at = now(), + last_queue_message_at = CASE + WHEN $2::int > 0 THEN now() + ($2::text || ' seconds')::interval + ELSE now() + END WHERE id = $1 AND status = 'running' `, - [jobId], + [jobId, delaySeconds], ); } @@ -664,7 +705,8 @@ export async function updateJobStep( env, ` UPDATE jobs - SET steps = CASE + SET heartbeat_at = now(), + steps = CASE WHEN EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(steps, '[]'::jsonb)) s WHERE s->>'name' = $2) THEN ( SELECT jsonb_agg( diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 8939be9..2dd1662 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -4,9 +4,56 @@ import { TimeoutError } from '@server/core/timeout'; import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Workers-AI call. */ -const CLOUDFLARE_TIMEOUT_MS = 120_000; +const CLOUDFLARE_TIMEOUT_MS = 180_000; const CLOUDFLARE_MAX_RETRIES = 0; const CLOUDFLARE_MAX_OUTPUT_TOKENS = 4096; +const REVIEW_RESPONSE_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['findings', 'overall_explanation', 'overall_correctness', 'overall_confidence_score'], + properties: { + findings: { + type: 'array', + maxItems: 10, + items: { + type: 'object', + additionalProperties: false, + required: ['title', 'body', 'priority', 'code_location'], + properties: { + title: { type: 'string', maxLength: 100 }, + body: { type: 'string' }, + confidence_score: { type: 'number', minimum: 0, maximum: 1 }, + priority: { type: 'integer', minimum: 0, maximum: 3 }, + code_location: { + type: 'object', + additionalProperties: false, + properties: { + absolute_file_path: { type: 'string' }, + line: { type: 'integer', minimum: 1 }, + line_range: { + type: 'object', + additionalProperties: false, + required: ['start', 'end'], + properties: { + start: { type: 'integer', minimum: 1 }, + end: { type: 'integer', minimum: 1 }, + }, + }, + }, + anyOf: [ + { required: ['line'] }, + { required: ['line_range'] }, + ], + }, + code_suggestion: { type: 'string' }, + }, + }, + }, + overall_explanation: { type: 'string' }, + overall_correctness: { type: 'string', enum: ['patch is correct', 'patch is incorrect'] }, + overall_confidence_score: { type: 'number', minimum: 0, maximum: 1 }, + }, +} as const; type UnknownRecord = Record; @@ -137,11 +184,23 @@ export async function reviewWithCloudflare( const result = await Promise.race([ env.AI.run(model as any, { messages: [ - { role: 'system', content: input.systemPrompt }, - { role: 'user', content: input.userPrompt }, + { + 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, + }, + }, temperature: 0, + top_p: 0.1, }), timeoutPromise, ]); diff --git a/src/server/models/google.ts b/src/server/models/google.ts index d85a60f..b52ea94 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -4,7 +4,7 @@ import { withTimeout } from '@server/core/timeout'; import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ -const GOOGLE_TIMEOUT_MS = 120_000; +const GOOGLE_TIMEOUT_MS = 180_000; const GOOGLE_MAX_RETRIES = 0; const GOOGLE_MAX_OUTPUT_TOKENS = 4096; diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index c494ad4..28503c6 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -7,6 +7,10 @@ import { jsonError } from '@server/core/http'; import { scheduleBestEffortJobMaintenance } from '@server/core/job-recovery'; import { loadRepoConfig } from '@server/core/config'; +function jobEtag(input: { id: string; status: string; updatedAt: string; fileCount: number; commentCount: number }) { + return `"job-${input.id}-${input.status}-${input.fileCount}-${input.commentCount}-${new Date(input.updatedAt).getTime()}"`; +} + function getExecutionContext(c: Context) { try { return c.executionCtx; @@ -31,12 +35,34 @@ export function createJobsRouter() { app.get('/:id', async (c) => { scheduleBestEffortJobMaintenance(c.env, getExecutionContext(c)); + const rawJob = await getJobForProcessing(c.env, c.req.param('id')); + if (!rawJob) { + return jsonError('Job not found.', 404); + } + + const summary = mapJob(rawJob); + const etag = jobEtag(summary); + const lastModified = new Date(summary.updatedAt).toUTCString(); + if (c.req.header('if-none-match') === etag) { + return new Response(null, { + status: 304, + headers: { + ETag: etag, + 'Last-Modified': lastModified, + }, + }); + } + const job = await getJobDetail(c.env, c.req.param('id')); if (!job) { return jsonError('Job not found.', 404); } - return c.json({ job }); + const response = c.json({ job }); + response.headers.set('ETag', etag); + response.headers.set('Last-Modified', lastModified); + response.headers.set('Cache-Control', 'private, no-cache'); + return response; }); app.post('/:id/retry', async (c) => { diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 63bc503..e9cf80d 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -184,6 +184,8 @@ export const jobSummarySchema = z.object({ totalInputTokens: z.number().int(), totalOutputTokens: z.number().int(), createdAt: dateStringSchema, + updatedAt: dateStringSchema, + nextRetryAt: dateStringSchema.nullable().optional(), startedAt: dateStringSchema.nullable(), finishedAt: dateStringSchema.nullable(), errorMessage: z.string().nullable(), diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 6acb814..c35c425 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -81,6 +81,43 @@ describe('ModelService', () => { expect(parsed.overall_explanation).toContain('inconclusive'); }); + it('asks Cloudflare chat models for strict review JSON', async () => { + let inputs: any; + const env = createTestEnv({ + AI: { + async run(_model: string, request: any) { + inputs = request; + return { + choices: [ + { + message: { + content: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}', + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + }, + } as any, + }); + + await reviewWithCloudflare(env, '@cf/zai-org/glm-4.7-flash', { + systemPrompt: 'system', + userPrompt: 'user', + }); + + expect(inputs.response_format).toMatchObject({ + type: 'json_schema', + json_schema: { + name: 'codra_file_review', + strict: true, + }, + }); + expect(inputs.messages[0].content).toContain('Return only the JSON object'); + expect(inputs.chat_template_kwargs).toBeUndefined(); + expect(inputs.reasoning_effort).toBeUndefined(); + }); + it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { let attempts = 0; const env = createTestEnv({ diff --git a/test/webhook-handling.spec.ts b/test/webhook-handling.spec.ts index dcaa885..965e603 100644 --- a/test/webhook-handling.spec.ts +++ b/test/webhook-handling.spec.ts @@ -119,7 +119,7 @@ describe('Webhook Handling Suite', () => { expect(queue.sent[0].payload).toBeUndefined(); }); - it('also accepts GitHub webhooks posted to the site root', async () => { + it('rejects GitHub webhooks posted to the site root', async () => { const repoName = `root-repo-${Date.now()}`; const rawPayload = createMockPRWebhook({ action: 'opened', @@ -145,10 +145,10 @@ describe('Webhook Handling Suite', () => { env, ); - const json = await response.json() as any; - expect(response.status).toBe(202); - expect(json.ok).toBe(true); - expect(json.message).toBe('queued'); + expect(response.status).toBe(404); + + const queue = env.REVIEW_QUEUE as any; + expect(queue.sent).toHaveLength(0); }); it('acknowledges unsupported GitHub events without queueing review work', async () => { From 2b2df7dfd65b6b2627fbd6475f99f3f3a5e3fa96 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 25 May 2026 06:23:00 +0530 Subject: [PATCH 12/32] add: improve model resilience and increase token limits --- src/server/core/review.ts | 2 +- src/server/models/cloudflare.ts | 8 +---- src/server/models/google.ts | 19 ++++++++--- test/model-service.spec.ts | 60 +++++++++++++++++++++++++++++++++ test/review-flow.spec.ts | 7 ++-- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 167492f..c4d4923 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -18,7 +18,7 @@ type PersistedReviewJob = ReturnType; export type ReviewJobRunResult = { action: 'ack' } | { action: 'retry'; delaySeconds: number }; -const REVIEW_CHUNK_FILE_LIMIT = 2; +const REVIEW_CHUNK_FILE_LIMIT = 3; const REVIEW_CHUNK_WALL_CLOCK_MS = 12 * 60 * 1000; const JOB_LEASE_SECONDS = 15 * 60; const BUSY_RETRY_SECONDS = 60; diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 2dd1662..36d4bc2 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -6,7 +6,7 @@ import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Workers-AI call. */ const CLOUDFLARE_TIMEOUT_MS = 180_000; const CLOUDFLARE_MAX_RETRIES = 0; -const CLOUDFLARE_MAX_OUTPUT_TOKENS = 4096; +const CLOUDFLARE_MAX_OUTPUT_TOKENS = 8192; const REVIEW_RESPONSE_SCHEMA = { type: 'object', additionalProperties: false, @@ -131,12 +131,6 @@ function extractCloudflareText(result: unknown, model: string): string { const finishReason = isRecord(choice) ? choice.finish_reason ?? choice.stop_reason : null; const reasoning = isText(message?.reasoning) ? message.reasoning : isText(message?.reasoning_content) ? message.reasoning_content : null; if (reasoning) { - if (reasoning.includes('{') && reasoning.includes('}')) { - logger.warn(`Cloudflare model ${model} returned reasoning without content; attempting to parse reasoning as review JSON`, { - finishReason, - }); - return reasoning.trim(); - } return synthesizeInconclusiveReview(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); } diff --git a/src/server/models/google.ts b/src/server/models/google.ts index b52ea94..ac31761 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -5,9 +5,13 @@ import type { ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ const GOOGLE_TIMEOUT_MS = 180_000; -const GOOGLE_MAX_RETRIES = 0; +const GOOGLE_MAX_RETRIES = 1; const GOOGLE_MAX_OUTPUT_TOKENS = 4096; +function isRetryableGoogleStatus(status: number) { + return status === 408 || status === 503 || status === 524; +} + export async function reviewWithGoogle( env: Pick, model: string, @@ -57,13 +61,18 @@ export async function reviewWithGoogle( if (!response.ok) { const errorText = await response.text(); const isRateLimit = response.status === 429; - const isRetryable = !isRateLimit && response.status >= 500; + const isRetryable = isRetryableGoogleStatus(response.status); - logger.error(`Google request failed with ${response.status}`, { + const logData = { error: errorText, attempt, - willRetry: isRetryable && attempt < maxRetries - }); + willRetry: isRetryable && attempt < maxRetries, + }; + if (isRetryable && attempt < maxRetries) { + logger.warn(`Google request failed with ${response.status}; retrying`, logData); + } else { + logger.error(`Google request failed with ${response.status}`, logData); + } if (isRateLimit) { throw new Error(`Google request failed with ${response.status}: ${errorText}`); diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index c35c425..72630e9 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; import { reviewWithCloudflare } from '@server/models/cloudflare'; +import { reviewWithGoogle } from '@server/models/google'; import { createTestEnv } from './helpers'; import { defaultRepoConfig } from '@shared/schema'; @@ -81,6 +82,36 @@ describe('ModelService', () => { expect(parsed.overall_explanation).toContain('inconclusive'); }); + it('does not parse Cloudflare reasoning as review JSON when final content is missing', async () => { + const env = createTestEnv({ + AI: { + async run() { + return { + choices: [ + { + message: { + content: null, + reasoning: 'Reasoning mentioned an object like {"foo":"bar"} but never produced final JSON.', + }, + finish_reason: 'length', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 8192 }, + }; + }, + } as any, + }); + + const response = await reviewWithCloudflare(env, '@cf/zai-org/glm-4.7-flash', { + systemPrompt: 'system', + userPrompt: 'user', + }); + const parsed = JSON.parse(response.rawText); + + expect(parsed.findings).toEqual([]); + expect(parsed.overall_explanation).toContain('reasoning-only response'); + }); + it('asks Cloudflare chat models for strict review JSON', async () => { let inputs: any; const env = createTestEnv({ @@ -114,10 +145,39 @@ describe('ModelService', () => { }, }); expect(inputs.messages[0].content).toContain('Return only the JSON object'); + expect(inputs.max_completion_tokens).toBe(8192); expect(inputs.chat_template_kwargs).toBeUndefined(); expect(inputs.reasoning_effort).toBeUndefined(); }); + it('retries Google once for transient 524 edge timeouts', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 524, message: 'A timeout occurred.' } }), + { status: 524, headers: { 'content-type': 'application/json' } }, + ), + ) + .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 response = await reviewWithGoogle( + { GEMINI_API_KEY: 'test-key' }, + 'gemma-4-31b-it', + { systemPrompt: 'system', userPrompt: 'user' }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.rawText).toContain('"findings"'); + }); + it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { let attempts = 0; const env = createTestEnv({ diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index b51bb85..dece307 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -412,6 +412,7 @@ 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; @@ -452,7 +453,7 @@ dbDescribe('Review Flow Lifecycle', () => { baseRef: 'main', configSnapshot: defaultRepoConfig, }); - await updateJobFileCount(env, job.id, 2); + await updateJobFileCount(env, job.id, 3); await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); await runWithDb(env, async () => { @@ -464,12 +465,12 @@ dbDescribe('Review Flow Lifecycle', () => { }); expect(result).toEqual({ action: 'ack' }); - expect(maxActive).toBe(2); + expect(maxActive).toBe(3); expect((env.REVIEW_QUEUE as any).sent[0]).toMatchObject({ jobId: job.id, phase: 'finalize' }); }); const reviews = await getFileReviewsForJobs(env, [job.id]); - expect(reviews.filter((review) => review.file_status === 'done')).toHaveLength(2); + expect(reviews.filter((review) => review.file_status === 'done')).toHaveLength(3); reviewSpy.mockRestore(); getDiffSpy.mockRestore(); From e996a541e97d5f2851cb63f8f8dae98c98b29d68 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 25 May 2026 22:27:06 +0530 Subject: [PATCH 13/32] feat: implement dashboard-managed LLM provider system --- .dev.vars.example | 4 +- .env.test.example | 1 + CONTRIBUTING.md | 3 +- README.md | 4 +- db/migrations/002_llm.sql | 144 +++ db/migrations/002_resumable_queue_jobs.sql | 30 - scripts/migrate.mjs | 14 +- .../features/models/model-chain.tsx | 88 +- src/client/components/ui/select.tsx | 9 +- src/client/lib/api.ts | 38 +- src/client/pages/repos.tsx | 32 +- src/client/pages/settings.tsx | 1020 +++++++++++++++-- src/server/core/llm-crypto.ts | 50 + src/server/core/review.ts | 6 +- src/server/db/model-configs.ts | 416 ++++++- src/server/env.ts | 2 +- src/server/models/anthropic.ts | 61 + src/server/models/catalog.ts | 208 ++++ src/server/models/cloudflare.ts | 12 +- src/server/models/google.ts | 56 +- src/server/models/openai.ts | 71 ++ src/server/models/types.ts | 27 +- src/server/routes/api/models.ts | 323 +++++- src/server/services/model.ts | 145 ++- src/server/worker-env.d.ts | 6 +- src/shared/api.ts | 2 + src/shared/schema.ts | 19 +- test/api.spec.ts | 152 ++- test/helpers.ts | 17 +- test/model-service.spec.ts | 31 +- worker-configuration.d.ts | 2 +- wrangler.jsonc | 2 +- 32 files changed, 2697 insertions(+), 298 deletions(-) create mode 100644 db/migrations/002_llm.sql delete mode 100644 db/migrations/002_resumable_queue_jobs.sql create mode 100644 src/server/core/llm-crypto.ts create mode 100644 src/server/models/anthropic.ts create mode 100644 src/server/models/catalog.ts create mode 100644 src/server/models/openai.ts diff --git a/.dev.vars.example b/.dev.vars.example index 1e05d90..c5819bc 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -5,8 +5,8 @@ # --- Integration tests --- TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra" -# --- AI provider --- -GEMINI_API_KEY="REPLACE_WITH_YOUR_GEMINI_API_KEY" +# --- LLM provider config encryption --- +LLM_CONFIG_ENCRYPTION_KEY="REPLACE_WITH_A_LONG_RANDOM_ENCRYPTION_KEY" # --- GitHub App and OAuth --- GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET" diff --git a/.env.test.example b/.env.test.example index 31e1b2e..0cf4c8d 100644 --- a/.env.test.example +++ b/.env.test.example @@ -12,6 +12,7 @@ DASHBOARD_ALLOWED_USERS="devarshishimpi" APP_URL="https://codra.test" BOT_USERNAME="codra-test-app" +LLM_CONFIG_ENCRYPTION_KEY="fake-local-llm-config-encryption-key" # Required. Must point at a disposable Postgres database because tests reset and # write data while running. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a0d1c0..c4ba31f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,8 @@ cp .dev.vars.example .dev.vars You will need to set up: - A GitHub App (for webhooks/checks). - A GitHub OAuth App (for dashboard authentication). -- A Gemini API Key. +- `LLM_CONFIG_ENCRYPTION_KEY` for encrypting dashboard-managed provider API keys. +- LLM providers and model credentials from the Settings dashboard. - A Hyperdrive local connection string for `wrangler dev`. - A direct `DATABASE_URL` for migrations. diff --git a/README.md b/README.md index eb897de..411c3bb 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - Dead letter queue inspection, replay, and purge workflows - GitHub OAuth dashboard authentication - External PostgreSQL storage through Cloudflare Hyperdrive -- Google Gemini and Cloudflare Workers AI model providers +- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models - Repository settings for labels, skipped globs, custom rules, and model routing ## How It Works @@ -65,7 +65,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts - **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV - **Queues**: Cloudflare Queues with DLQ workflows -- **Models**: Google Gemini and Cloudflare Workers AI +- **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/002_llm.sql b/db/migrations/002_llm.sql new file mode 100644 index 0000000..9921c5c --- /dev/null +++ b/db/migrations/002_llm.sql @@ -0,0 +1,144 @@ +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx + ON jobs (lease_expires_at) + WHERE status = 'running' AND lease_expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx + ON jobs (status, check_run_completed_at) + WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; + +CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx + ON jobs (last_queue_message_at, heartbeat_at) + WHERE status = 'running' AND lease_expires_at IS NULL; + +DELETE FROM file_reviews fr +USING ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number + FROM file_reviews +) ranked +WHERE fr.id = ranked.id + AND ranked.row_number > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key + ON file_reviews (job_id, file_path); + +CREATE TABLE IF NOT EXISTS llm_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), + base_url TEXT, + encrypted_api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +UPDATE llm_providers +SET name = 'Cloudflare', updated_at = now() +WHERE name = 'Cloudflare Workers AI'; + +UPDATE llm_providers +SET name = 'Google', updated_at = now() +WHERE name = 'Google Gemini'; + +INSERT INTO llm_providers (name, api_format, base_url, enabled) +VALUES + ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), + ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), + ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), + ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), + ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) +ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now(); + +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID; +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT; + +UPDATE model_configs mc +SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id) +FROM llm_providers provider_record +WHERE mc.provider_id IS NULL + AND ( + (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') + OR (mc.provider = 'gemini' AND provider_record.name = 'Google') + OR (mc.provider = 'google' AND provider_record.name = 'Google') + OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') + OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + ); + +UPDATE model_configs +SET model_name = model_id +WHERE model_name IS NULL; + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT '@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare', p.id, '@cf/moonshotai/kimi-k2.6', 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, + updated_at = now(); + +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() +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, + updated_at = now(); + +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() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +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() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL; +ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' + ) THEN + ALTER TABLE model_configs + ADD CONSTRAINT model_configs_provider_id_fkey + FOREIGN KEY (provider_id) REFERENCES llm_providers(id); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id); diff --git a/db/migrations/002_resumable_queue_jobs.sql b/db/migrations/002_resumable_queue_jobs.sql deleted file mode 100644 index b521486..0000000 --- a/db/migrations/002_resumable_queue_jobs.sql +++ /dev/null @@ -1,30 +0,0 @@ -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; -ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx - ON jobs (lease_expires_at) - WHERE status = 'running' AND lease_expires_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx - ON jobs (status, check_run_completed_at) - WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; - -CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx - ON jobs (last_queue_message_at, heartbeat_at) - WHERE status = 'running' AND lease_expires_at IS NULL; - -DELETE FROM file_reviews fr -USING ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number - FROM file_reviews -) ranked -WHERE fr.id = ranked.id - AND ranked.row_number > 1; - -CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key - ON file_reviews (job_id, file_path); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 9063499..b3e7214 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -231,8 +231,18 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) - VALUES ($1, 10, 131072, 300, 'cloudflare') + INSERT INTO llm_providers (name, api_format, base_url, enabled) + VALUES ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE) + ON CONFLICT (name) DO NOTHING + `, + ); + + await query( + ` + INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name) + SELECT $1, 10, 131072, 300, 'cloudflare', id, $1 + FROM llm_providers + WHERE name = 'Cloudflare' ON CONFLICT (model_id) DO NOTHING `, [kimiK26Model], diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index b31dbce..cb27b60 100644 --- a/src/client/components/features/models/model-chain.tsx +++ b/src/client/components/features/models/model-chain.tsx @@ -4,17 +4,16 @@ import { Select } from '@client/components/ui/select'; import { Button } from '@client/components/ui/button'; import { Trash2, ListPlus } from 'lucide-react'; -export const PROVIDERS = [ - { value: 'cloudflare', label: 'Cloudflare' }, - { value: 'google', label: 'Google' }, -]; +export type ProviderOption = { + value: string; + label: string; +}; -export const MODELS = [ - { value: 'gemma-4-31b-it', label: 'Gemma 4 (31b)', provider: 'google' }, - { value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 (26b)', provider: 'google' }, - { value: '@cf/moonshotai/kimi-k2.6', label: 'Kimi K2.6', provider: 'cloudflare' }, - { value: '@cf/zai-org/glm-4.7-flash', label: 'GLM 4.7 Flash', provider: 'cloudflare' }, -]; +export type ModelOption = { + value: string; + label: string; + providerId: string; +}; export type ModelDensity = 'compact' | 'comfortable'; @@ -30,19 +29,19 @@ export type ModelRouteConfig = { size_overrides: ModelRouteTier[]; }; -export function getProviderLabel(provider: string) { - return PROVIDERS.find(p => p.value === provider)?.label ?? provider; +export function getProviderLabel(provider: string, providers: ProviderOption[] = []) { + return providers.find(p => p.value === provider)?.label ?? provider; } -export function getModelLabel(model: string) { - return MODELS.find(m => m.value === model)?.label ?? model; +export function getModelLabel(model: string, models: ModelOption[] = []) { + return models.find(m => m.value === model)?.label ?? model; } -export function describeModelRoute(config: ModelRouteConfig) { +export function describeModelRoute(config: ModelRouteConfig, models: ModelOption[] = []) { const fallbacks = config.fallbacks?.length ?? 0; const tiers = config.size_overrides?.length ?? 0; return [ - getModelLabel(config.main), + getModelLabel(config.main, models), fallbacks > 0 ? `${fallbacks} fallback${fallbacks === 1 ? '' : 's'}` : 'no fallbacks', tiers > 0 ? `${tiers} tier${tiers === 1 ? '' : 's'}` : 'baseline only', ].join(' · '); @@ -51,6 +50,8 @@ export function describeModelRoute(config: ModelRouteConfig) { interface ModelSelectorProps { value: string; onValueChange: (value: string) => void; + models: ModelOption[]; + providers: ProviderOption[]; hideLabels?: boolean; density?: ModelDensity; className?: string; @@ -59,25 +60,35 @@ interface ModelSelectorProps { export function ModelSelector({ value, onValueChange, + models, + providers, hideLabels, density = 'comfortable', className, }: ModelSelectorProps) { - const currentModel = MODELS.find(m => m.value === value) || MODELS[0]; - const [provider, setProvider] = useState(currentModel.provider); + const currentModel = models.find(m => m.value === value) || models[0]; + const [provider, setProvider] = useState(currentModel?.providerId ?? providers[0]?.value ?? ''); useEffect(() => { - const model = MODELS.find(m => m.value === value); - if (model && model.provider !== provider) { - setProvider(model.provider); + const model = models.find(m => m.value === value); + if (model && model.providerId !== provider) { + setProvider(model.providerId); } - }, [provider, value]); + }, [models, provider, value]); const filteredModels = useMemo( - () => MODELS.filter(m => m.provider === provider).map(m => ({ value: m.value, label: m.label })), - [provider], + () => models.filter(m => m.providerId === provider).map(m => ({ value: m.value, label: m.label })), + [models, provider], ); + if (models.length === 0 || providers.length === 0) { + return ( +
+ No configured models +
+ ); + } + return (
{ setProvider(nextProvider); - const first = MODELS.find(m => m.provider === nextProvider); + const first = models.find(m => m.providerId === nextProvider); if (first) onValueChange(first.value); }} - options={PROVIDERS} + options={providers} triggerClassName={cn(density === 'compact' && 'h-8 text-xs')} /> { + const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0]; + setNewProvider(current => ({ + ...current, + preset: preset.value, + name: preset.name, + apiFormat: preset.apiFormat, + baseUrl: preset.baseUrl, + })); + }} + options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))} + /> + + + + +
+ {selectedProviderNameExists && ( +

+ {newProvider.name.trim()} already exists. +

+ )} +
+ )} + +
+ Provider + Type + Models + Credential + Actions +
+ +
+ {providers.map(provider => { + const nativeCloudflare = provider.apiFormat === 'cloudflare-workers-ai'; + const customProvider = isCustomProvider(provider); + const ready = providerIsReady(provider); + 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; + return ( +
+
+
+ + {ready ? : } + +
+

{provider.name}

+
+ + {providerStatusLabel(provider)} + + {formatLabel(provider.apiFormat)} + {modelCount} {modelCount === 1 ? 'model' : 'models'} +
+
+
+ +

{formatLabel(provider.apiFormat)}

+

{modelCount}

+

+ {nativeCloudflare ? 'Worker AI binding' : provider.hasApiKey ? 'Saved key hidden' : 'No API key'} +

+ +
+ + + + {customProvider && ( + + )} +
+
+ + {expanded && ( +
+ {customProvider && ( +
+ + updateProviderDraft(provider.id, { baseUrl: e.target.value || null })} + /> + +
+ )} + + {nativeCloudflare ? ( +
+ Native provider. Calls use the Worker AI binding configured in Wrangler. +
+ ) : ( + + )} +
+ )} +
+ ); + })} +
+
+
+ )} + +
@@ -234,11 +907,13 @@ export function SettingsPage() {
) : ( -
+
@@ -253,98 +928,237 @@ export function SettingsPage() {
-

Model usage quotas

+

Models and usage limits

- Provider rate limits and token capacity per model. + Codra model IDs, provider model names, and rate metadata.

{loading ? (
{[1, 2, 3].map(i => ( -
+
))}
) : ( -
- {configs.map((cfg, i) => { - const saved = savedConfigs.find(item => item.modelId === cfg.modelId); - const dirty = !quotaEqual(cfg, saved); - return ( -
+
+
+ + Add custom model +
+
+ setNewModel(current => ({ ...current, modelId: e.target.value }))} + /> + setNewModel(current => ({ ...current, modelName: e.target.value }))} + /> + setNewModel(current => ({ ...current, [field]: Number(e.target.value) || 1 }))} + /> + ))} + +
+
+ +
+
+ + updateQuota(cfg.modelId, field, Number(e.target.value) || 0)} - className="mt-2 h-8 min-w-0 w-full bg-transparent text-left text-lg font-semibold text-foreground outline-none" - /> - - ))} + {expanded && ( +
+
+ updateModel(cfg.modelId, { modelName: e.target.value })} + className="mt-1.5" + /> + + {(['rpm', 'rpd', 'tpm'] as const).map(field => ( + + ))} +
+
+ )} +
+ ); + })} + + {filteredConfigs.length === 0 && ( +
+ No models match the current filters.
- - ); - })} + )} +
+
)} diff --git a/src/server/core/llm-crypto.ts b/src/server/core/llm-crypto.ts new file mode 100644 index 0000000..805980e --- /dev/null +++ b/src/server/core/llm-crypto.ts @@ -0,0 +1,50 @@ +import type { AppBindings } from '@server/env'; + +const KEY_VERSION = 'v1'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function toBase64(bytes: Uint8Array) { + return Buffer.from(bytes).toString('base64'); +} + +function fromBase64(value: string) { + return new Uint8Array(Buffer.from(value, 'base64')); +} + +async function importEncryptionKey(secret: string) { + if (!secret || secret.trim().length < 16) { + throw new Error('LLM_CONFIG_ENCRYPTION_KEY must be at least 16 characters long.'); + } + + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(secret)); + return crypto.subtle.importKey('raw', digest, 'AES-GCM', false, ['encrypt', 'decrypt']); +} + +export async function encryptLlmApiKey(env: Pick, apiKey: string) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await importEncryptionKey(env.LLM_CONFIG_ENCRYPTION_KEY); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(apiKey), + ); + + return `${KEY_VERSION}:${toBase64(iv)}:${toBase64(new Uint8Array(ciphertext))}`; +} + +export async function decryptLlmApiKey(env: Pick, encrypted: string) { + const [version, ivBase64, ciphertextBase64] = encrypted.split(':'); + if (version !== KEY_VERSION || !ivBase64 || !ciphertextBase64) { + throw new Error('Unsupported encrypted LLM API key format.'); + } + + const key = await importEncryptionKey(env.LLM_CONFIG_ENCRYPTION_KEY); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromBase64(ivBase64) }, + key, + fromBase64(ciphertextBase64), + ); + + return decoder.decode(plaintext); +} diff --git a/src/server/core/review.ts b/src/server/core/review.ts index c4d4923..36f2867 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -562,7 +562,7 @@ async function reviewAndPersistFile( const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, - modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + modelProvider: 'configured', diffLineCount: file.lineCount, diffInput: '', durationMs: Date.now() - startedAt, @@ -575,7 +575,7 @@ async function reviewAndPersistFile( filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + modelProvider: 'configured', diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, @@ -621,7 +621,7 @@ async function reviewAndPersistFile( filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + modelProvider: 'configured', diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index 7755cf1..f15979a 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -1,31 +1,221 @@ import type { AppBindings } from '@server/env'; import { queryRows } from './client'; -import { KIMI_K2_5_MODEL, modelConfigSchema, type ModelConfig } from '@shared/schema'; +import { + KIMI_K2_5_MODEL, + llmProviderSchema, + modelConfigSchema, + type LlmApiFormat, + type LlmProvider, + type ModelConfig, +} from '@shared/schema'; + +type ProviderRow = { + id: string; + name: string; + api_format: LlmApiFormat; + base_url: string | null; + encrypted_api_key: string | null; + enabled: boolean; + created_at: string; + updated_at: string; +}; type ModelConfigRow = { model_id: string; + provider_id: string; + provider_name: string; + api_format: LlmApiFormat; + model_name: string; rpm: number; tpm: number; rpd: number; - provider: string; updated_at: string; }; +export type LlmProviderSecret = LlmProvider & { + encryptedApiKey: string | null; +}; + +export type ResolvedModelConfig = ModelConfig & { + providerEnabled: boolean; + baseUrl: string | null; + encryptedApiKey: string | null; +}; + +function mapProvider(row: ProviderRow): LlmProvider { + return llmProviderSchema.parse({ + id: row.id, + name: row.name, + apiFormat: row.api_format, + baseUrl: row.base_url, + enabled: row.enabled, + hasApiKey: Boolean(row.encrypted_api_key), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function mapProviderSecret(row: ProviderRow): LlmProviderSecret { + return { + ...mapProvider(row), + encryptedApiKey: row.encrypted_api_key, + }; +} + function mapModelConfig(row: ModelConfigRow): ModelConfig { return modelConfigSchema.parse({ modelId: row.model_id, + providerId: row.provider_id, + providerName: row.provider_name, + apiFormat: row.api_format, + modelName: row.model_name, rpm: row.rpm, tpm: row.tpm, rpd: row.rpd, - provider: row.provider, updatedAt: row.updated_at, }); } +const MODEL_SELECT = ` + SELECT + mc.model_id, + mc.provider_id, + 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 +`; + +export async function listLlmProviders(env: Pick): Promise { + const rows = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + ORDER BY name ASC`, + ); + return rows.map(mapProvider); +} + +export async function listLlmProviderSecrets(env: Pick): Promise { + const rows = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + ORDER BY name ASC`, + ); + return rows.map(mapProviderSecret); +} + +export async function getLlmProvider(env: Pick, id: string): Promise { + const [row] = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + WHERE id = $1`, + [id], + ); + return row ? mapProviderSecret(row) : null; +} + +export async function createLlmProvider( + env: Pick, + input: { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + encryptedApiKey: string | null; + enabled: boolean; + }, +) { + const [row] = await queryRows( + env, + ` + INSERT INTO llm_providers (name, api_format, base_url, encrypted_api_key, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + RETURNING id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + `, + [input.name, input.apiFormat, input.baseUrl, input.encryptedApiKey, input.enabled], + ); + return mapProvider(row); +} + +export async function findLlmProviderByName(env: Pick, name: string): Promise { + const [row] = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + WHERE lower(name) = lower($1)`, + [name], + ); + return row ? mapProvider(row) : null; +} + +export async function updateLlmProvider( + env: Pick, + id: string, + input: { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + encryptedApiKey?: string | null; + enabled: boolean; + }, +) { + const params: unknown[] = [id, input.name, input.apiFormat, input.baseUrl, input.enabled]; + let apiKeySql = ''; + if (input.encryptedApiKey !== undefined) { + params.push(input.encryptedApiKey); + apiKeySql = `, encrypted_api_key = $${params.length}`; + } + + const [row] = await queryRows( + env, + ` + UPDATE llm_providers + SET + name = $2, + api_format = $3, + base_url = $4, + enabled = $5, + updated_at = now() + ${apiKeySql} + WHERE id = $1 + RETURNING id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + `, + params, + ); + return row ? mapProvider(row) : null; +} + +export async function deleteLlmProvider(env: Pick, id: string) { + const [{ count }] = await queryRows<{ count: string }>( + env, + `SELECT COUNT(*)::text AS count FROM model_configs WHERE provider_id = $1`, + [id], + ); + if (Number(count) > 0) { + return { deleted: false, reason: 'Provider is still used by one or more models.' }; + } + + const rows = await queryRows<{ id: string }>( + env, + `DELETE FROM llm_providers WHERE id = $1 RETURNING id`, + [id], + ); + return { deleted: rows.length > 0, reason: null }; +} + export async function listModelConfigs(env: Pick): Promise { const rows = await queryRows( env, - `SELECT model_id, rpm, tpm, rpd, provider, updated_at FROM model_configs WHERE model_id <> $1 ORDER BY model_id ASC`, + `${MODEL_SELECT} + WHERE mc.model_id <> $1 + ORDER BY mc.model_id ASC`, [KIMI_K2_5_MODEL], ); return rows.map(mapModelConfig); @@ -34,29 +224,219 @@ export async function listModelConfigs(env: Pick): Pr export async function getModelConfig(env: Pick, modelId: string): Promise { const [row] = await queryRows( env, - `SELECT model_id, rpm, tpm, rpd, provider, updated_at FROM model_configs WHERE model_id = $1`, - [modelId] + `${MODEL_SELECT} + WHERE mc.model_id = $1`, + [modelId], ); return row ? mapModelConfig(row) : null; } +export async function getResolvedModelConfig( + env: Pick, + modelId: string, +): Promise { + const [row] = await queryRows( + env, + ` + SELECT + mc.model_id, + mc.provider_id, + 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, + p.encrypted_api_key + FROM model_configs mc + JOIN llm_providers p ON p.id = mc.provider_id + WHERE mc.model_id = $1 + `, + [modelId], + ); + + if (!row) return null; + return { + ...mapModelConfig(row), + providerEnabled: row.provider_enabled, + baseUrl: row.base_url, + encryptedApiKey: row.encrypted_api_key, + }; +} + export async function updateModelConfig( env: Pick, - config: Omit + config: Omit, ) { - await queryRows( + const [row] = await queryRows( env, ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, updated_at) - VALUES ($1, $2, $3, $4, $5, now()) - ON CONFLICT (model_id) - DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - updated_at = now() + 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() + 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 + ) + SELECT + u.model_id, + u.provider_id, + 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], + ); + return row ? mapModelConfig(row) : null; +} + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'provider'; +} + +export async function upsertDiscoveredModelConfigs( + env: Pick, + input: { + providerId: string; + providerName: string; + apiFormat: LlmApiFormat; + modelNames: string[]; + }, +) { + const uniqueModelNames = Array.from(new Set(input.modelNames.map(name => name.trim()).filter(Boolean))); + if (uniqueModelNames.length === 0) return []; + + const [existingForProvider, existingModelIds] = await Promise.all([ + queryRows<{ model_id: string; model_name: string }>( + env, + `SELECT model_id, model_name FROM model_configs WHERE provider_id = $1`, + [input.providerId], + ), + queryRows<{ model_id: string }>( + env, + `SELECT model_id FROM model_configs`, + ), + ]); + + const existingModelNames = new Set(existingForProvider.map(row => row.model_name)); + const usedModelIds = new Set(existingModelIds.map(row => row.model_id)); + const providerSlug = slugify(input.providerName); + const rowsToInsert: Array<{ + model_id: string; + provider_id: string; + model_name: string; + rpm: number; + tpm: number; + rpd: number; + provider: LlmApiFormat; + }> = []; + + for (const modelName of uniqueModelNames) { + if (existingModelNames.has(modelName)) continue; + + const base = `${providerSlug}:${modelName}`; + let candidate = base; + let suffix = 2; + while (usedModelIds.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix++; + } + usedModelIds.add(candidate); + + rowsToInsert.push({ + model_id: candidate, + provider_id: input.providerId, + model_name: modelName, + rpm: 60, + tpm: 1_000_000, + rpd: 1_000, + provider: input.apiFormat, + }); + } + + if (rowsToInsert.length === 0) return []; + + 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( + env, + ` + WITH incoming AS ( + SELECT * + FROM unnest( + $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) + ), + 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() + FROM incoming + ON CONFLICT (model_id) DO NOTHING + RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at + ) + SELECT + i.model_id, + i.provider_id, + 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 `, - [config.modelId, config.rpm, config.tpm, config.rpd, config.provider] + [modelIds, providerIds, modelNames, rpms, tpms, rpds, providers], + ); + + return rows.map(mapModelConfig); +} + +export async function deleteModelConfig(env: Pick, modelId: string) { + const rows = await queryRows<{ model_id: string }>( + env, + `DELETE FROM model_configs WHERE model_id = $1 RETURNING model_id`, + [modelId], ); + return rows.length > 0; } diff --git a/src/server/env.ts b/src/server/env.ts index 03a765b..34dddf1 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -40,7 +40,7 @@ export interface AppBindings { AUTH_CALLBACK_URL: string; APP_URL: string; DASHBOARD_ALLOWED_USERS: string; - GEMINI_API_KEY: string; + LLM_CONFIG_ENCRYPTION_KEY: string; BOT_USERNAME: string; ENVIRONMENT: string; CF_API_TOKEN: string; diff --git a/src/server/models/anthropic.ts b/src/server/models/anthropic.ts new file mode 100644 index 0000000..364e9c2 --- /dev/null +++ b/src/server/models/anthropic.ts @@ -0,0 +1,61 @@ +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_MAX_OUTPUT_TOKENS = 4096; +const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; + +export async function reviewWithAnthropic( + config: { apiKey: string; baseUrl?: string | null; providerName: string }, + 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(/\/+$/, ''); + + if (tracker) tracker.incrementSubrequests(1); + const response = await withTimeout('Anthropic API', ANTHROPIC_TIMEOUT_MS, (signal) => + fetch(`${baseUrl}/messages`, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + 'x-api-key': config.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + system: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`, + messages: [ + { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` }, + ], + max_tokens: ANTHROPIC_MAX_OUTPUT_TOKENS, + temperature: 0, + }), + }), + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); + } + + const data = await response.json() as any; + const rawText = Array.isArray(data.content) + ? data.content.map((part: any) => typeof part?.text === 'string' ? part.text : '').join('').trim() + : ''; + + if (!rawText) { + throw new Error('Anthropic provider returned an empty response.'); + } + + return { + rawText, + inputTokens: data?.usage?.input_tokens ?? 0, + outputTokens: data?.usage?.output_tokens ?? 0, + modelUsed: model, + provider: config.providerName, + }; +} diff --git a/src/server/models/catalog.ts b/src/server/models/catalog.ts new file mode 100644 index 0000000..9dbdfc1 --- /dev/null +++ b/src/server/models/catalog.ts @@ -0,0 +1,208 @@ +import type { LlmApiFormat } from '@shared/schema'; +import { withTimeout } from '@server/core/timeout'; + +const MODEL_LIST_TIMEOUT_MS = 8_000; +const ERROR_BODY_LIMIT = 500; +const CLOUDFLARE_TEXT_GENERATION_MODELS = [ + '@cf/moonshotai/kimi-k2.6', + '@cf/zai-org/glm-4.7-flash', + '@cf/openai/gpt-oss-120b', + '@cf/meta/llama-4-scout-17b-16e-instruct', + '@cf/google/gemma-4-26b-a4b-it', + '@cf/nvidia/nemotron-3-120b-a12b', + '@cf/moonshotai/kimi-k2.5', + '@cf/ibm/granite-4.0-h-micro', + '@cf/aisingapore/gemma-sea-lion-v4-27b-it', + '@cf/openai/gpt-oss-20b', + '@cf/qwen/qwen3-30b-a3b-fp8', + '@cf/google/gemma-3-12b-it', + '@cf/mistral/mistral-small-3.1-24b-instruct', + '@cf/qwen/qwq-32b', + '@cf/qwen/qwen2.5-coder-32b-instruct', + '@cf/meta/llama-guard-3-8b', + '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + '@cf/meta/llama-3.2-1b-instruct', + '@cf/meta/llama-3.2-3b-instruct', + '@cf/meta/llama-3.2-11b-vision-instruct', + '@cf/meta/llama-3.1-8b-instruct-awq', + '@cf/meta/llama-3.1-8b-instruct-fp8', + '@cf/meta/llama-3.1-8b-instruct', + '@cf/meta-llama/meta-llama-3-8b-instruct', + '@cf/meta/llama-3-8b-instruct-awq', + '@cf/meta/llama-3-8b-instruct', + '@cf/mistral/mistral-7b-instruct-v0.2', + '@cf/google/gemma-7b-it-lora', + '@cf/google/gemma-2b-it-lora', + '@cf/meta-llama/llama-2-7b-chat-hf-lora', + '@cf/google/gemma-7b-it', + '@cf/nexusflow/starling-lm-7b-beta', + '@cf/nousresearch/hermes-2-pro-mistral-7b', + '@cf/mistral/mistral-7b-instruct-v0.2-lora', + '@cf/qwen/qwen1.5-1.8b-chat', + '@cf/microsoft/phi-2', + '@cf/tinyllama/tinyllama-1.1b-chat-v1.0', + '@cf/qwen/qwen1.5-14b-chat-awq', + '@cf/qwen/qwen1.5-7b-chat-awq', + '@cf/qwen/qwen1.5-0.5b-chat', + '@cf/thebloke/discolm-german-7b-v1-awq', + '@cf/tiiuae/falcon-7b-instruct', + '@cf/openchat/openchat-3.5-0106', + '@cf/defog/sqlcoder-7b-2', + '@cf/deepseek-ai/deepseek-math-7b-instruct', + '@cf/thebloke/deepseek-coder-6.7b-instruct-awq', + '@cf/thebloke/deepseek-coder-6.7b-base-awq', + '@cf/thebloke/llamaguard-7b-awq', + '@cf/thebloke/neural-chat-7b-v3-1-awq', + '@cf/thebloke/openhermes-2.5-mistral-7b-awq', + '@cf/thebloke/llama-2-13b-chat-awq', + '@cf/thebloke/mistral-7b-instruct-v0.1-awq', + '@cf/thebloke/zephyr-7b-beta-awq', + '@cf/meta/llama-2-7b-chat-fp16', + '@cf/mistral/mistral-7b-instruct-v0.1', + '@cf/meta/llama-2-7b-chat-int8', + '@cf/meta/llama-3.1-70b-instruct', + '@cf/meta/llama-3.1-8b-instruct-fast', +]; + +function cleanGeminiModelName(name: string) { + return name.startsWith('models/') ? name.slice('models/'.length) : name; +} + +function extractOpenAiModels(data: any) { + return Array.isArray(data?.data) + ? data.data.map((item: any) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + : []; +} + +function extractAnthropicModels(data: any) { + return Array.isArray(data?.data) + ? data.data.map((item: any) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + : []; +} + +function extractGeminiModels(data: any) { + if (!Array.isArray(data?.models)) return []; + return data.models + .filter((model: any) => Array.isArray(model?.supportedGenerationMethods) + ? model.supportedGenerationMethods.includes('generateContent') + : true) + .map((model: any) => typeof model?.name === 'string' ? cleanGeminiModelName(model.name) : null) + .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0); +} + +export async function listProviderModels(input: { + apiFormat: LlmApiFormat; + baseUrl: string | null; + apiKey?: string; + cloudflareAccountId?: string; + cloudflareApiToken?: string; +}) { + const baseUrl = (input.baseUrl || defaultBaseUrl(input.apiFormat)).replace(/\/+$/, ''); + + if (input.apiFormat === 'openai') { + if (!input.apiKey) throw new Error('OpenAI API key is required to list models.'); + const apiKey = input.apiKey; + const response = await withTimeout('OpenAI model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(`${baseUrl}/models`, { + signal, + headers: { + authorization: `Bearer ${apiKey}`, + }, + }), + ); + if (!response.ok) throw new Error(`OpenAI model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractOpenAiModels(await response.json()); + } + + if (input.apiFormat === 'anthropic') { + if (!input.apiKey) throw new Error('Anthropic API key is required to list models.'); + const apiKey = input.apiKey; + const response = await withTimeout('Anthropic model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(`${baseUrl}/models`, { + signal, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }), + ); + if (!response.ok) throw new Error(`Anthropic model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractAnthropicModels(await response.json()); + } + + if (input.apiFormat === 'gemini') { + if (!input.apiKey) throw new Error('Google API key is required to list models.'); + const apiKey = input.apiKey; + const url = `${baseUrl}/models?key=${encodeURIComponent(apiKey)}`; + const response = await withTimeout('Google model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(url, { signal }), + ); + if (!response.ok) throw new Error(`Google model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractGeminiModels(await response.json()); + } + + return listCloudflareModels(input.cloudflareAccountId, input.cloudflareApiToken); +} + +async function listCloudflareModels(accountId?: string, apiToken?: string) { + if (!accountId || !apiToken) return CLOUDFLARE_TEXT_GENERATION_MODELS; + + const url = new URL(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/search`); + url.searchParams.set('task', 'Text Generation'); + url.searchParams.set('per_page', '100'); + + const response = await withTimeout('Cloudflare model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(url.toString(), { + signal, + headers: { + authorization: `Bearer ${apiToken}`, + }, + }), + ); + + if (response.status === 401 || response.status === 403) { + return CLOUDFLARE_TEXT_GENERATION_MODELS; + } + if (!response.ok) throw new Error(`Cloudflare model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + const models = extractCloudflareModels(await response.json()); + return models.length > 0 ? models : CLOUDFLARE_TEXT_GENERATION_MODELS; +} + +function extractCloudflareModels(data: any) { + const items = Array.isArray(data?.result) + ? data.result + : Array.isArray(data?.result?.data) + ? data.result.data + : Array.isArray(data?.data) + ? data.data + : []; + + return Array.from(new Set( + items + .map((item: any) => normalizeCloudflareModelId(item?.id ?? item?.name ?? item?.model ?? item?.model_id)) + .filter((id: unknown): id is string => typeof id === 'string' && id.startsWith('@cf/')), + )); +} + +function normalizeCloudflareModelId(value: unknown) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('@cf/')) return trimmed; + if (trimmed.startsWith('cf/')) return `@${trimmed}`; + return null; +} + +async function limitedErrorBody(response: Response) { + const body = await response.text().catch(() => ''); + if (!body) return response.statusText || 'request failed'; + return body.length > ERROR_BODY_LIMIT ? `${body.slice(0, ERROR_BODY_LIMIT)}...` : body; +} + +function defaultBaseUrl(apiFormat: LlmApiFormat) { + if (apiFormat === 'cloudflare-workers-ai') return ''; + if (apiFormat === 'gemini') return 'https://generativelanguage.googleapis.com/v1beta'; + if (apiFormat === 'anthropic') return 'https://api.anthropic.com/v1'; + return 'https://api.openai.com/v1'; +} diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 36d4bc2..1e58899 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -1,7 +1,7 @@ import { logger } from '@server/core/logger'; import type { AppBindings } from '@server/env'; import { TimeoutError } from '@server/core/timeout'; -import type { ModelResponse } from './types'; +import { ProviderRequestError, type ModelResponse } from './types'; /** Max wall-clock time allowed for a single Workers-AI call. */ const CLOUDFLARE_TIMEOUT_MS = 180_000; @@ -154,6 +154,7 @@ export async function reviewWithCloudflare( model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, + providerName = 'Cloudflare', ): Promise { const maxRetries = CLOUDFLARE_MAX_RETRIES; let lastError: unknown; @@ -209,11 +210,18 @@ export async function reviewWithCloudflare( inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, modelUsed: model, - provider: 'cloudflare', + provider: providerName, }; } catch (error) { lastError = error; const errorMsg = error instanceof Error ? error.message : String(error); + + if (errorMsg.includes('Binding AI needs to be run remotely')) { + const message = 'Cloudflare Workers AI is not available in local Wrangler. Run with remote bindings or deploy the Worker to test Cloudflare models.'; + logger.warn(message, { model }); + throw new ProviderRequestError(providerName, 400, message); + } + logger.error(`Cloudflare request failed (attempt ${attempt}/${maxRetries})`, { error: errorMsg }); // If we've used up our neuron quota, don't retry - it's a persistent error for this account/day diff --git a/src/server/models/google.ts b/src/server/models/google.ts index ac31761..997fda9 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -1,27 +1,28 @@ import { logger } from '@server/core/logger'; -import type { AppBindings } from '@server/env'; import { withTimeout } from '@server/core/timeout'; -import type { ModelResponse } from './types'; +import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ -const GOOGLE_TIMEOUT_MS = 180_000; -const GOOGLE_MAX_RETRIES = 1; -const GOOGLE_MAX_OUTPUT_TOKENS = 4096; +const GEMINI_TIMEOUT_MS = 180_000; +const GEMINI_MAX_RETRIES = 1; +const GEMINI_MAX_OUTPUT_TOKENS = 4096; +const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; -function isRetryableGoogleStatus(status: number) { - return status === 408 || status === 503 || status === 524; +function isRetryableGeminiStatus(status: number) { + return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; } export async function reviewWithGoogle( - env: Pick, + config: { apiKey: string; baseUrl?: string | null; providerName?: string }, model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, ): Promise { - logger.info(`Calling Google AI model: ${model}`); + logger.info(`Calling Google model: ${model}`); const startTime = Date.now(); - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${env.GEMINI_API_KEY}`; - const maxRetries = GOOGLE_MAX_RETRIES; + const baseUrl = (config.baseUrl || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, ''); + const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`; + const maxRetries = GEMINI_MAX_RETRIES; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { @@ -29,11 +30,11 @@ export async function reviewWithGoogle( if (tracker) tracker.incrementSubrequests(1); if (attempt > 0) { const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000; - logger.info(`Retrying Google request (attempt ${attempt}/${maxRetries}) in ${Math.round(delay)}ms`); + logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delay)}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } - const response = await withTimeout('Google API', GOOGLE_TIMEOUT_MS, (signal) => + const response = await withTimeout('Gemini API', GEMINI_TIMEOUT_MS, (signal) => fetch(url, { method: 'POST', signal, @@ -52,7 +53,7 @@ export async function reviewWithGoogle( ], generationConfig: { responseMimeType: 'application/json', - maxOutputTokens: GOOGLE_MAX_OUTPUT_TOKENS, + maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, }, }), }), @@ -60,29 +61,22 @@ export async function reviewWithGoogle( if (!response.ok) { const errorText = await response.text(); - const isRateLimit = response.status === 429; - const isRetryable = isRetryableGoogleStatus(response.status); + const message = providerErrorMessage(errorText); + const isRetryable = isRetryableGeminiStatus(response.status); const logData = { - error: errorText, + error: message, attempt, willRetry: isRetryable && attempt < maxRetries, }; if (isRetryable && attempt < maxRetries) { - logger.warn(`Google request failed with ${response.status}; retrying`, logData); - } else { - logger.error(`Google request failed with ${response.status}`, logData); - } - - if (isRateLimit) { - throw new Error(`Google request failed with ${response.status}: ${errorText}`); - } - - if (isRetryable && attempt < maxRetries) { - lastError = new Error(`Google request failed with ${response.status}: ${errorText}`); + logger.warn(`Gemini request failed with ${response.status}; retrying`, logData); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); continue; } - throw new Error(`Google request failed with ${response.status}: ${errorText}`); + + logger.error(`Gemini request failed with ${response.status}`, logData); + throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message); } const durationMs = Date.now() - startTime; @@ -98,7 +92,7 @@ export async function reviewWithGoogle( const rawText = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); if (!rawText) { - throw new Error('Google returned an empty response.'); + throw new Error('Gemini returned an empty response.'); } return { @@ -106,7 +100,7 @@ export async function reviewWithGoogle( inputTokens: data.usageMetadata?.promptTokenCount ?? 0, outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, modelUsed: model, - provider: 'google', + provider: config.providerName ?? 'Google', }; } catch (error) { lastError = error; diff --git a/src/server/models/openai.ts b/src/server/models/openai.ts new file mode 100644 index 0000000..1d25446 --- /dev/null +++ b/src/server/models/openai.ts @@ -0,0 +1,71 @@ +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_MAX_OUTPUT_TOKENS = 4096; + +function extractOpenAiText(data: any) { + const messageContent = data?.choices?.[0]?.message?.content; + if (typeof messageContent === 'string') return messageContent.trim(); + if (Array.isArray(messageContent)) { + return messageContent.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim(); + } + const outputText = data?.output_text; + if (typeof outputText === 'string') return outputText.trim(); + return ''; +} + +export async function reviewWithOpenAI( + config: { apiKey: string | null; baseUrl: string; providerName: string }, + model: string, + input: { systemPrompt: string; userPrompt: string }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + logger.info(`Calling OpenAI-format model: ${model}`); + const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`; + + if (tracker) tracker.incrementSubrequests(1); + const response = await withTimeout('OpenAI API', OPENAI_TIMEOUT_MS, (signal) => + fetch(url, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}), + }, + body: JSON.stringify({ + model, + 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.` }, + ], + temperature: 0, + max_tokens: OPENAI_MAX_OUTPUT_TOKENS, + response_format: { type: 'json_object' }, + }), + }), + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); + } + + const data = await response.json() as any; + const rawText = extractOpenAiText(data); + if (!rawText) { + throw new Error('OpenAI provider returned an empty response.'); + } + + return { + rawText, + inputTokens: data?.usage?.prompt_tokens ?? data?.usage?.input_tokens ?? 0, + outputTokens: data?.usage?.completion_tokens ?? data?.usage?.output_tokens ?? 0, + modelUsed: model, + provider: config.providerName, + }; +} diff --git a/src/server/models/types.ts b/src/server/models/types.ts index c51aea5..dcbc0bf 100644 --- a/src/server/models/types.ts +++ b/src/server/models/types.ts @@ -3,5 +3,30 @@ export type ModelResponse = { inputTokens: number; outputTokens: number; modelUsed: string; - provider: 'google' | 'cloudflare'; + provider: string; }; + +export class ProviderRequestError extends Error { + constructor( + public readonly provider: string, + public readonly status: number, + message: string, + ) { + super(`${provider} request failed with ${status}: ${message}`); + this.name = 'ProviderRequestError'; + } +} + +export function providerErrorMessage(errorText: string) { + try { + const parsed = JSON.parse(errorText) as any; + const message = parsed?.error?.message ?? parsed?.message ?? parsed?.error; + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + } catch { + // Fall back to the provider body below. + } + + return errorText.trim() || 'The provider returned an error.'; +} diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index 1a09283..68e41f0 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -1,19 +1,54 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '@server/env'; -import { listModelConfigs, updateModelConfig } from '@server/db/model-configs'; +import { + createLlmProvider, + deleteLlmProvider, + deleteModelConfig, + findLlmProviderByName, + getResolvedModelConfig, + listLlmProviderSecrets, + listLlmProviders, + listModelConfigs, + updateLlmProvider, + updateModelConfig, + upsertDiscoveredModelConfigs, +} from '@server/db/model-configs'; import { jsonError } from '@server/core/http'; import { getGlobalConfig, updateGlobalConfig } from '@server/core/config'; +import { encryptLlmApiKey, decryptLlmApiKey } from '@server/core/llm-crypto'; +import { llmApiFormats } from '@shared/schema'; +import { reviewWithCloudflare } from '@server/models/cloudflare'; +import { reviewWithGoogle } from '@server/models/google'; +import { reviewWithOpenAI } from '@server/models/openai'; +import { reviewWithAnthropic } from '@server/models/anthropic'; +import { listProviderModels } from '@server/models/catalog'; +import { ProviderRequestError } from '@server/models/types'; -const providerSchema = z.enum(['google', 'cloudflare']); +const apiFormatSchema = z.enum(llmApiFormats); const positiveIntegerSchema = z.number().int().positive().finite(); const modelIdSchema = z.string().trim().min(1); +const optionalUrlSchema = z.string().trim().url().nullable().optional(); +const providerIdSchema = z.string().uuid(); + +const providerCreateSchema = z.object({ + name: z.string().trim().min(1), + apiFormat: apiFormatSchema, + baseUrl: optionalUrlSchema, + apiKey: z.string().optional(), + enabled: z.boolean().default(true), +}).strict(); + +const providerUpdateSchema = providerCreateSchema.extend({ + clearApiKey: z.boolean().optional(), +}).strict(); const modelConfigUpdateSchema = z.object({ + providerId: providerIdSchema, + modelName: z.string().trim().min(1), rpm: positiveIntegerSchema, tpm: positiveIntegerSchema, rpd: positiveIntegerSchema, - provider: providerSchema, }).strict(); const globalModelConfigSchema = z.object({ @@ -31,14 +66,117 @@ const globalModelConfigSchema = z.object({ .optional(), }).strict(); +function normalizedBaseUrl(apiFormat: z.infer, baseUrl?: string | null) { + if (apiFormat === 'cloudflare-workers-ai') return null; + if (baseUrl) return baseUrl.replace(/\/+$/, ''); + if (apiFormat === 'gemini') return 'https://generativelanguage.googleapis.com/v1beta'; + if (apiFormat === 'anthropic') return 'https://api.anthropic.com/v1'; + return 'https://api.openai.com/v1'; +} + +async function encryptedApiKeyFromBody(env: AppEnv['Bindings'], apiKey?: string, clearApiKey?: boolean) { + if (clearApiKey) return null; + if (apiKey === undefined) return undefined; + const trimmed = apiKey.trim(); + if (!trimmed) return undefined; + return encryptLlmApiKey(env, trimmed); +} + +function isEncryptionConfigError(error: unknown) { + return error instanceof Error && error.message.includes('LLM_CONFIG_ENCRYPTION_KEY'); +} + +function isUniqueNameError(error: unknown) { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: string }).code === '23505', + ); +} + +function readModelIdParam(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function providerErrorStatus(error: ProviderRequestError) { + return error.status >= 500 ? 502 : error.status; +} + +function optionalEnv(value: () => string) { + try { + const resolved = value().trim(); + return resolved.length > 0 ? resolved : undefined; + } catch { + return undefined; + } +} + +async function syncProviderModelCatalog(env: AppEnv['Bindings']) { + const providers = await listLlmProviderSecrets(env); + const syncErrors: Array<{ providerId: string; providerName: string; error: string }> = []; + + await Promise.all(providers.map(async (provider) => { + if (!provider.enabled) { + return; + } + if (provider.apiFormat !== 'cloudflare-workers-ai' && !provider.encryptedApiKey) { + return; + } + + try { + const apiKey = provider.encryptedApiKey + ? await decryptLlmApiKey(env, provider.encryptedApiKey) + : undefined; + const modelNames = await listProviderModels({ + apiFormat: provider.apiFormat, + baseUrl: provider.baseUrl, + apiKey, + cloudflareAccountId: optionalEnv(() => env.CF_ACCOUNT_ID), + cloudflareApiToken: optionalEnv(() => env.CF_API_TOKEN), + }); + await upsertDiscoveredModelConfigs(env, { + providerId: provider.id, + providerName: provider.name, + apiFormat: provider.apiFormat, + modelNames, + }); + } catch (error) { + syncErrors.push({ + providerId: provider.id, + providerName: provider.name, + error: error instanceof Error ? error.message : 'Could not refresh provider models.', + }); + } + })); + + return syncErrors; +} + export function createModelsRouter() { const app = new Hono(); app.get('/', async (c) => { - const configs = await listModelConfigs(c.env); - return c.json({ configs }); + const [providers, configs] = await Promise.all([ + listLlmProviders(c.env), + listModelConfigs(c.env), + ]); + return c.json({ providers, configs }); }); - + + app.post('/sync', async (c) => { + const syncErrors = await syncProviderModelCatalog(c.env); + const [providers, configs] = await Promise.all([ + listLlmProviders(c.env), + listModelConfigs(c.env), + ]); + return c.json({ providers, configs, syncErrors }); + }); + app.get('/global', async (c) => { const config = await getGlobalConfig(c.env); return c.json({ config }); @@ -55,8 +193,160 @@ export function createModelsRouter() { return c.json({ ok: true }); }); + app.post('/providers', async (c) => { + const parsed = providerCreateSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return jsonError('Invalid provider config.', 400); + } + + const input = parsed.data; + const existing = await findLlmProviderByName(c.env, input.name); + if (existing) { + return jsonError(`Provider ${input.name} already exists. Update the existing provider instead.`, 409); + } + + let encryptedApiKey: string | null = null; + try { + encryptedApiKey = input.apiFormat === 'cloudflare-workers-ai' + ? null + : (await encryptedApiKeyFromBody(c.env, input.apiKey)) ?? null; + } catch (error) { + if (isEncryptionConfigError(error)) { + return jsonError(error instanceof Error ? error.message : 'LLM encryption is not configured.', 400); + } + throw error; + } + + let provider; + try { + provider = await createLlmProvider(c.env, { + name: input.name, + apiFormat: input.apiFormat, + baseUrl: normalizedBaseUrl(input.apiFormat, input.baseUrl), + encryptedApiKey, + enabled: input.enabled, + }); + } catch (error) { + if (isUniqueNameError(error)) { + return jsonError(`Provider ${input.name} already exists. Update the existing provider instead.`, 409); + } + throw error; + } + + return c.json({ provider }, 201); + }); + + app.patch('/providers/:id', async (c) => { + const id = c.req.param('id'); + if (!providerIdSchema.safeParse(id).success) { + return jsonError('Invalid provider id.', 400); + } + + const parsed = providerUpdateSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return jsonError('Invalid provider config.', 400); + } + + const input = parsed.data; + let encryptedApiKey: string | null | undefined; + try { + encryptedApiKey = input.apiFormat === 'cloudflare-workers-ai' + ? null + : await encryptedApiKeyFromBody(c.env, input.apiKey, input.clearApiKey); + } catch (error) { + if (isEncryptionConfigError(error)) { + return jsonError(error instanceof Error ? error.message : 'LLM encryption is not configured.', 400); + } + throw error; + } + + let provider; + try { + provider = await updateLlmProvider(c.env, id, { + name: input.name, + apiFormat: input.apiFormat, + baseUrl: normalizedBaseUrl(input.apiFormat, input.baseUrl), + ...(encryptedApiKey !== undefined ? { encryptedApiKey } : {}), + enabled: input.enabled, + }); + } catch (error) { + if (isUniqueNameError(error)) { + return jsonError(`Provider ${input.name} already exists. Choose a different provider name.`, 409); + } + throw error; + } + + if (!provider) return jsonError('Provider not found.', 404); + return c.json({ provider }); + }); + + app.delete('/providers/:id', async (c) => { + const id = c.req.param('id'); + if (!providerIdSchema.safeParse(id).success) { + return jsonError('Invalid provider id.', 400); + } + + const result = await deleteLlmProvider(c.env, id); + if (!result.deleted) { + return jsonError(result.reason ?? 'Provider not found.', result.reason ? 409 : 404); + } + return c.json({ ok: true }); + }); + + app.post('/:id/test', async (c) => { + const modelId = readModelIdParam(c.req.param('id')); + const parsedModelId = modelIdSchema.safeParse(modelId); + if (!parsedModelId.success) { + return jsonError('Invalid model id.', 400); + } + + const config = await getResolvedModelConfig(c.env, parsedModelId.data); + if (!config) return jsonError('Model not found.', 404); + if (!config.providerEnabled) return jsonError('Provider is disabled.', 400); + + try { + const input = { + systemPrompt: 'Return only JSON.', + userPrompt: 'Return {"ok":true}.', + }; + let response; + if (config.apiFormat === 'cloudflare-workers-ai') { + response = await reviewWithCloudflare(c.env, config.modelName, input, undefined, config.providerName); + } else { + if (!config.encryptedApiKey) { + return jsonError(`Provider ${config.providerName} does not have a saved API key.`, 400); + } + const apiKey = await decryptLlmApiKey(c.env, config.encryptedApiKey); + if (config.apiFormat === 'gemini') { + response = await reviewWithGoogle({ apiKey, baseUrl: config.baseUrl, providerName: config.providerName }, config.modelName, input); + } else if (config.apiFormat === 'openai') { + response = await reviewWithOpenAI({ + apiKey, + baseUrl: config.baseUrl || 'https://api.openai.com/v1', + providerName: config.providerName, + }, config.modelName, input); + } else { + response = await reviewWithAnthropic({ apiKey, baseUrl: config.baseUrl, providerName: config.providerName }, config.modelName, input); + } + } + + return c.json({ + ok: true, + modelUsed: response.modelUsed, + provider: response.provider, + inputTokens: response.inputTokens, + outputTokens: response.outputTokens, + }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : 'Connection test failed.', + error instanceof ProviderRequestError ? providerErrorStatus(error) : 502, + ); + } + }); + app.post('/:id', async (c) => { - const modelId = c.req.param('id'); + const modelId = readModelIdParam(c.req.param('id')); const parsedModelId = modelIdSchema.safeParse(modelId); if (!parsedModelId.success) { return jsonError('Invalid model id.', 400); @@ -67,12 +357,25 @@ export function createModelsRouter() { if (!parsed.success) { return jsonError('Invalid model config.', 400); } - - await updateModelConfig(c.env, { + + const saved = await updateModelConfig(c.env, { modelId: parsedModelId.data, ...parsed.data, }); - + + if (!saved) return jsonError('Provider not found.', 404); + return c.json({ ok: true, config: saved }); + }); + + app.delete('/:id', async (c) => { + const modelId = readModelIdParam(c.req.param('id')); + const parsedModelId = modelIdSchema.safeParse(modelId); + if (!parsedModelId.success) { + return jsonError('Invalid model id.', 400); + } + + const deleted = await deleteModelConfig(c.env, parsedModelId.data); + if (!deleted) return jsonError('Model not found.', 404); return c.json({ ok: true }); }); diff --git a/src/server/services/model.ts b/src/server/services/model.ts index a06b604..6cb36c9 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -1,6 +1,8 @@ import type { AppBindings } from '../env'; import { reviewWithGoogle } from '../models/google'; import { reviewWithCloudflare } from '../models/cloudflare'; +import { reviewWithOpenAI } from '../models/openai'; +import { reviewWithAnthropic } from '../models/anthropic'; import { buildFileReviewPrompts } from '../prompts/file-review'; import { buildSummaryPrompt, SUMMARY_SYSTEM_PROMPT } from '../prompts/summary'; import { parseFileReviewResponse } from '../core/model-output'; @@ -10,15 +12,15 @@ import type { TokenTracker } from '../core/token-tracker'; import type { ModelResponse } from '../models/types'; 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'; -const DEFAULT_GOOGLE_FALLBACK = 'gemma-4-31b-it'; const PROVIDER_UNAVAILABLE_TTL_SECONDS = 24 * 60 * 60; const COMPACT_REVIEW_PROMPT_LINE_CAP = 400; const MODEL_ALIASES: Record = { 'gemma-4-31b': 'gemma-4-31b-it', 'gemma-4-26b': 'gemma-4-26b-a4b-it', }; -type ModelProvider = 'cloudflare' | 'google'; export class RetryableModelError extends Error { readonly retryable = true; @@ -40,14 +42,6 @@ export function isRetryableModelError(error: unknown) { return Boolean(error && typeof error === 'object' && 'retryable' in error && error.retryable === true); } -function isCloudflareModel(model: string) { - return model.startsWith('@cf/'); -} - -function getModelProvider(model: string): ModelProvider { - return isCloudflareModel(model) ? 'cloudflare' : 'google'; -} - function normalizeModel(model: string) { return normalizeModelId(MODEL_ALIASES[model] ?? model); } @@ -96,26 +90,26 @@ export class ModelService { private options: { jobId?: string } = {}, ) {} - private providerUnavailableKey(provider: ModelProvider) { - return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${provider}` : null; + private providerUnavailableKey(providerId: string) { + return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${providerId}` : null; } - private async isProviderUnavailable(provider: ModelProvider) { - const key = this.providerUnavailableKey(provider); + private async isProviderUnavailable(providerId: string) { + const key = this.providerUnavailableKey(providerId); if (!key) return false; try { return (await this.env.APP_KV.get(key)) !== null; } catch (error) { - logger.warn(`Failed to read unavailable provider marker for ${provider}`, { + logger.warn(`Failed to read unavailable provider marker for ${providerId}`, { error: error instanceof Error ? error.message : String(error), }); return false; } } - private async markProviderUnavailable(provider: ModelProvider, reason: string) { - const key = this.providerUnavailableKey(provider); + private async markProviderUnavailable(providerId: string, reason: string) { + const key = this.providerUnavailableKey(providerId); if (!key) return; try { @@ -128,7 +122,7 @@ export class ModelService { { expirationTtl: PROVIDER_UNAVAILABLE_TTL_SECONDS }, ); } catch (error) { - logger.warn(`Failed to write unavailable provider marker for ${provider}`, { + logger.warn(`Failed to write unavailable provider marker for ${providerId}`, { error: error instanceof Error ? error.message : String(error), }); } @@ -165,23 +159,71 @@ export class ModelService { const chain = uniqueModels([selectedModel, ...fallbackModels]); selectedModel = chain[0] ?? 'gemma-4-31b-it'; fallbackModels = chain.slice(1); - if (chain.length > 0 && chain.every(isCloudflareModel)) { - fallbackModels = [...fallbackModels, DEFAULT_GOOGLE_FALLBACK]; - } return { primary: selectedModel, fallbacks: fallbackModels }; } - private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }): Promise { - model = normalizeModel(model); - // Determine provider based on model name - // Cloudflare models start with @cf/ - if (model.startsWith('@cf/')) { - return await reviewWithCloudflare(this.env, model, input, this.tracker); - } else { - // Default to Google for gemma/gemini - return await reviewWithGoogle(this.env, model, input, this.tracker); + private async resolveModel(model: string) { + const normalized = normalizeModel(model); + const resolved = await getResolvedModelConfig(this.env, normalized); + if (!resolved) { + throw new Error(`Model ${normalized} is not configured. Add it in Settings before using it in a route.`); + } + + if (!resolved.providerEnabled) { + throw new Error(`Provider ${resolved.providerName} is disabled.`); + } + + return resolved; + } + + private async decryptApiKey(config: ResolvedModelConfig) { + if (!config.encryptedApiKey) { + throw new Error(`Provider ${config.providerName} does not have a saved API key.`); } + return decryptLlmApiKey(this.env, config.encryptedApiKey); + } + + private async callResolvedModel( + config: ResolvedModelConfig, + input: { systemPrompt: string; userPrompt: string }, + ): Promise { + if (config.apiFormat === 'cloudflare-workers-ai') { + return reviewWithCloudflare(this.env, config.modelName, input, this.tracker, config.providerName); + } + + if (config.apiFormat === 'gemini') { + return reviewWithGoogle( + { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName }, + 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, + ); + } + + return reviewWithAnthropic( + { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName }, + config.modelName, + input, + this.tracker, + ); + } + + private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }): Promise { + return this.callResolvedModel(await this.resolveModel(model), input); } async reviewFile(params: { @@ -213,9 +255,19 @@ export class ModelService { let lastTransientError: unknown; let sawTransientFailure = false; for (const currentModel of modelsToTry) { - const provider = getModelProvider(currentModel); - if (provider === 'cloudflare' && await this.isProviderUnavailable('cloudflare')) { - logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); + let resolved: ResolvedModelConfig; + try { + resolved = await this.resolveModel(currentModel); + } catch (error) { + lastError = error; + logger.warn(`Model ${currentModel} could not be resolved`, { + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + if (resolved.apiFormat === 'cloudflare-workers-ai' && await this.isProviderUnavailable(resolved.providerId)) { + logger.warn(`Skipping ${resolved.providerName} model ${currentModel} because the provider is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } @@ -224,7 +276,7 @@ export class ModelService { while (attempts < maxAttempts) { try { - const response = await this.callModel(currentModel, { systemPrompt, userPrompt }); + const response = await this.callResolvedModel(resolved, { systemPrompt, userPrompt }); if (this.tracker) { this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens); @@ -245,8 +297,8 @@ export class ModelService { lastTransientError = error; } attempts++; - if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - await this.markProviderUnavailable('cloudflare', error instanceof Error ? error.message : String(error)); + if (resolved.apiFormat === 'cloudflare-workers-ai' && isCloudflareAllocationError(error)) { + await this.markProviderUnavailable(resolved.providerId, error instanceof Error ? error.message : String(error)); } const isRateLimit = isGoogleRateLimitError(error); @@ -293,13 +345,24 @@ export class ModelService { let lastTransientError: unknown; let sawTransientFailure = false; for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && await this.isProviderUnavailable('cloudflare')) { - logger.warn(`Skipping Cloudflare summary model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); + let resolved: ResolvedModelConfig; + try { + resolved = await this.resolveModel(currentModel); + } catch (error) { + lastError = error; + logger.warn(`Summary model ${currentModel} could not be resolved`, { + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + if (resolved.apiFormat === 'cloudflare-workers-ai' && await this.isProviderUnavailable(resolved.providerId)) { + logger.warn(`Skipping ${resolved.providerName} summary model ${currentModel} because the provider is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } try { - const response = await this.callModel(currentModel, { + const response = await this.callResolvedModel(resolved, { systemPrompt: SUMMARY_SYSTEM_PROMPT, userPrompt: buildSummaryPrompt(params), }); @@ -315,8 +378,8 @@ export class ModelService { sawTransientFailure = true; lastTransientError = error; } - if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - await this.markProviderUnavailable('cloudflare', error instanceof Error ? error.message : String(error)); + if (resolved.apiFormat === 'cloudflare-workers-ai' && isCloudflareAllocationError(error)) { + await this.markProviderUnavailable(resolved.providerId, error instanceof Error ? error.message : String(error)); } logger.warn(`Summary model ${currentModel} failed`, { error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/worker-env.d.ts b/src/server/worker-env.d.ts index 664f4e6..1a20668 100644 --- a/src/server/worker-env.d.ts +++ b/src/server/worker-env.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 2cf95a373086a6483897fb06140fae41) +// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 63b433e2d7525f4fc91fc4ed25ea92e2) // Runtime types generated with workerd@1.20260521.1 2026-04-16 nodejs_compat interface __BaseEnv_Env { APP_KV: KVNamespace; @@ -19,7 +19,7 @@ interface __BaseEnv_Env { GITHUB_APP_WEBHOOK_SECRET: string; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; - GEMINI_API_KEY: string; + LLM_CONFIG_ENCRYPTION_KEY: string; CF_API_TOKEN: string; CF_ACCOUNT_ID: string; } @@ -34,7 +34,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/src/shared/api.ts b/src/shared/api.ts index 14671de..4bbfd25 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -70,5 +70,7 @@ export type DlqResponse = { }; export type ModelConfigsResponse = { + providers: import('./schema').LlmProvider[]; configs: import('./schema').ModelConfig[]; + syncErrors?: Array<{ providerId: string; providerName: string; error: string }>; }; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index e9cf80d..e88bb4e 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -6,6 +6,7 @@ export const fileStatuses = ['pending', 'done', 'skipped', 'failed'] as const; export const reviewVerdicts = ['approve', 'comment'] as const; export const reviewSeverities = ['P0', 'P1', 'P2', 'P3', 'nit'] as const; export const reviewCategories = ['security', 'bugs', 'performance', 'correctness', 'quality'] as const; // Keeping for DB compatibility but will deprecate usage in prompts +export const llmApiFormats = ['openai', 'anthropic', 'gemini', 'cloudflare-workers-ai'] as const; export const dateStringSchema = z.union([z.string(), z.date()]).transform((d) => (d instanceof Date ? d.toISOString() : d)); export const coerceNumberSchema = z.coerce.number(); @@ -338,15 +339,31 @@ export type JobSummary = z.infer; export type FileReviewRecord = z.infer; export type JobDetail = z.infer; export type RepoConfigRecord = z.infer; +export const llmProviderSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + apiFormat: z.enum(llmApiFormats), + baseUrl: z.string().nullable(), + enabled: z.boolean(), + hasApiKey: z.boolean(), + createdAt: dateStringSchema, + updatedAt: dateStringSchema, +}); + export const modelConfigSchema = z.object({ modelId: z.string(), + providerId: z.string().uuid(), + providerName: z.string(), + apiFormat: z.enum(llmApiFormats), + modelName: z.string(), rpm: z.number().int(), tpm: z.number().int(), rpd: z.number().int(), - provider: z.string(), updatedAt: dateStringSchema, }); +export type LlmApiFormat = z.infer['apiFormat']; +export type LlmProvider = z.infer; export type ModelConfig = z.infer; export type StatsPayload = z.infer; diff --git a/test/api.spec.ts b/test/api.spec.ts index ce29724..1d2fe94 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -10,11 +10,12 @@ import type { AuthSessionResponse, JobDetailResponse, JobsResponse, + ModelConfigsResponse, RepoConfigsResponse, StatsResponse, UpdatesEmailResponse, } from '@shared/api'; -import { createTestEnv } from './helpers'; +import { createTestEnv, saveTestProviderApiKey } from './helpers'; import { vi } from 'vitest'; function mockGitHubProfile(login = 'devarshishimpi') { @@ -384,6 +385,155 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(400); }); + it('returns model configs without refreshing remote provider catalogs', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('unexpected catalog fetch')); + fetchSpy.mockClear(); + + const response = await app.request('/api/models', { + headers: { Cookie: `codra_session=${token}` }, + }, env); + + expect(response.status).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refreshes provider model catalogs on the explicit sync endpoint', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.includes('/ai/models/search')) { + return Response.json({ + success: false, + errors: [{ code: 10000, message: 'Authentication error' }], + messages: [], + result: null, + }, { status: 403 }); + } + return Response.json({ + models: [ + { + name: 'models/gemini-2.5-flash', + supportedGenerationMethods: ['generateContent'], + }, + ], + }); + }); + + const response = await app.request('/api/models/sync', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + }, env); + + expect(response.status).toBe(200); + const data = await response.json() as ModelConfigsResponse; + expect(data.configs.some(config => config.modelName === 'gemini-2.5-flash')).toBe(true); + expect(data.configs.some(config => config.providerName === 'Cloudflare' && config.modelName === '@cf/openai/gpt-oss-120b')).toBe(true); + expect(data.syncErrors).toEqual([]); + }); + + it('tests models whose ids contain URL path separators', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + const modelId = '@cf/zai-org/glm-4.7-flash'; + + const response = await app.request(`/api/models/${encodeURIComponent(modelId)}/test`, { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(200); + const data = await response.json() as { modelUsed: string; provider: string }; + expect(data.modelUsed).toBe(modelId); + expect(data.provider).toBe('Cloudflare'); + }); + + it('returns provider status codes for model test failures', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ + error: { + code: 429, + message: 'Quota exceeded. Please retry later.', + status: 'RESOURCE_EXHAUSTED', + }, + }, { status: 429 })); + + const response = await app.request('/api/models/gemma-4-31b-it/test', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(429); + const data = await response.json() as { error: string }; + expect(data.error).toContain('Quota exceeded'); + expect(data.error).not.toContain('"details"'); + }); + + it('reports local Cloudflare Workers AI binding limitations clearly', async () => { + const env = createTestEnv({ + AI: { + async run() { + throw new Error('Binding AI needs to be run remotely'); + }, + } as any, + }); + const token = await getAuthCookie(env); + + const response = await app.request(`/api/models/${encodeURIComponent('@cf/zai-org/glm-4.7-flash')}/test`, { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(400); + const data = await response.json() as { error: string }; + expect(data.error).toContain('Cloudflare Workers AI is not available in local Wrangler'); + }); + + it('maps upstream provider server errors to bad gateway after retry', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ + error: { + code: 500, + message: 'Internal error encountered.', + }, + }, { status: 500 })); + fetchMock.mockClear(); + + const response = await app.request('/api/models/gemma-4-31b-it/test', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(502); + expect(fetchMock).toHaveBeenCalledTimes(2); + const data = await response.json() as { error: string }; + expect(data.error).toContain('Internal error encountered.'); + }); + it('rejects invalid global model config writes', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); diff --git a/test/helpers.ts b/test/helpers.ts index 440fd50..5a8e0be 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,6 @@ import type { AppBindings } from '@server/env'; +import { encryptLlmApiKey } from '@server/core/llm-crypto'; +import { queryRows } from '@server/db/client'; export class MemoryKV { private readonly store = new Map(); @@ -100,7 +102,7 @@ export function createTestEnv(overrides: Partial = {}): AppBindings AUTH_CALLBACK_URL: requiredEnv('AUTH_CALLBACK_URL'), APP_URL: requiredEnv('APP_URL'), DASHBOARD_ALLOWED_USERS: requiredEnv('DASHBOARD_ALLOWED_USERS'), - get GEMINI_API_KEY() { return unusedEnv('GEMINI_API_KEY'); }, + LLM_CONFIG_ENCRYPTION_KEY: 'test-llm-config-encryption-key', BOT_USERNAME: requiredEnv('BOT_USERNAME'), get ENVIRONMENT() { return unusedEnv('ENVIRONMENT'); }, get CF_API_TOKEN() { return unusedEnv('CF_API_TOKEN'); }, @@ -110,6 +112,19 @@ export function createTestEnv(overrides: Partial = {}): AppBindings }; } +export async function saveTestProviderApiKey(env: AppBindings, providerName = 'Google', apiKey = 'test-key') { + const encrypted = await encryptLlmApiKey(env, apiKey); + await queryRows( + env, + ` + UPDATE llm_providers + SET encrypted_api_key = $1, enabled = TRUE, updated_at = now() + WHERE name = $2 + `, + [encrypted, providerName], + ); +} + /** * Generates a mock Unified Diff string for testing. */ diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 72630e9..e134e2c 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; import { reviewWithCloudflare } from '@server/models/cloudflare'; import { reviewWithGoogle } from '@server/models/google'; -import { createTestEnv } from './helpers'; +import { createTestEnv, saveTestProviderApiKey } from './helpers'; import { defaultRepoConfig } from '@shared/schema'; describe('ModelService', () => { @@ -169,7 +169,7 @@ describe('ModelService', () => { ); const response = await reviewWithGoogle( - { GEMINI_API_KEY: 'test-key' }, + { apiKey: 'test-key' }, 'gemma-4-31b-it', { systemPrompt: 'system', userPrompt: 'user' }, ); @@ -213,6 +213,18 @@ describe('ModelService', () => { { status: 500, headers: { 'content-type': 'application/json' } }, ), ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + code: 500, + message: 'Internal error encountered.', + status: 'INTERNAL', + }, + }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ), + ) .mockResolvedValueOnce( new Response( JSON.stringify({ @@ -237,8 +249,8 @@ describe('ModelService', () => { }; }, } as any, - GEMINI_API_KEY: 'test-key', }); + await saveTestProviderApiKey(env); const service = new ModelService(env); const response = await service.reviewFile({ @@ -264,9 +276,10 @@ describe('ModelService', () => { totalLineCount: 1, }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemma-4-31b-it:generateContent'); - expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemma-4-26b-a4b-it:generateContent'); + expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemma-4-31b-it:generateContent'); + expect(String(fetchMock.mock.calls[2][0])).toContain('/models/gemma-4-26b-a4b-it:generateContent'); expect(cloudflareCalls).toBe(0); expect(response.modelUsed).toBe('gemma-4-26b-a4b-it'); }); @@ -342,8 +355,8 @@ describe('ModelService', () => { throw new Error('Cloudflare daily free allocation exhausted (4006)'); }, } as any, - GEMINI_API_KEY: 'test-key', }); + await saveTestProviderApiKey(env); const service = new ModelService(env, undefined, { jobId: 'job-provider-skip' }); const file = { path: 'src/app.ts', @@ -394,7 +407,8 @@ describe('ModelService', () => { { status: 200, headers: { 'content-type': 'application/json' } }, ); }); - const env = createTestEnv({ GEMINI_API_KEY: 'test-key' }); + const env = createTestEnv(); + await saveTestProviderApiKey(env); const service = new ModelService(env); const largeFile = { path: 'src/large.ts', @@ -453,7 +467,8 @@ describe('ModelService', () => { { status: 200, headers: { 'content-type': 'application/json' } }, ); }); - const env = createTestEnv({ GEMINI_API_KEY: 'test-key' }); + const env = createTestEnv(); + await saveTestProviderApiKey(env); const service = new ModelService(env); const largeFile = { path: 'src/large.ts', diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 54e9805..3dbb3e7 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -11,7 +11,7 @@ declare interface Env { APP_PRIVATE_KEY: string; GITHUB_APP_ID: string; GITHUB_APP_WEBHOOK_SECRET: string; - GEMINI_API_KEY: string; + LLM_CONFIG_ENCRYPTION_KEY: string; GEMINI_MODEL: string; BOT_USERNAME: string; ENVIRONMENT: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index dcd3b96..80ceecc 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -83,7 +83,7 @@ "GITHUB_APP_WEBHOOK_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", - "GEMINI_API_KEY", + "LLM_CONFIG_ENCRYPTION_KEY", "CF_API_TOKEN", "CF_ACCOUNT_ID" ] From ff77850cadcca9fb138d0f463e4b81d330b1113a Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 25 May 2026 23:46:06 +0530 Subject: [PATCH 14/32] refactor: label management and add provider validation --- .dev.vars.example | 2 + src/client/pages/settings.tsx | 1118 ++++++++++++++++--------------- src/server/core/github.ts | 26 + src/server/core/review.ts | 20 +- src/server/routes/api/models.ts | 19 + src/server/services/github.ts | 5 +- test/api.spec.ts | 54 ++ test/review-flow.spec.ts | 1 + 8 files changed, 699 insertions(+), 546 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index c5819bc..da963bf 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -16,6 +16,8 @@ GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" # --- Cloudflare API --- +# Required permissions: Queues Edit for DLQ actions, Workers AI Read for +# Cloudflare model catalog discovery. CF_ACCOUNT_ID="REPLACE_WITH_YOUR_CLOUDFLARE_ACCOUNT_ID" CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN" diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index df297cf..5a85135 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -20,6 +20,9 @@ import { CheckCircle2, AlertTriangle, Search, + ChevronDown, + ChevronRight, + X, } from 'lucide-react'; import type { LlmApiFormat, LlmProvider, ModelConfig } from '@shared/schema'; import type { ModelConfigsResponse } from '@shared/api'; @@ -133,6 +136,10 @@ function providerIsReady(provider: Pick) { + return provider.apiFormat === 'cloudflare-workers-ai' || provider.hasApiKey || provider.apiKey.trim().length > 0; +} + function providerStatusLabel(provider: Pick) { if (!provider.enabled) return 'Off'; return providerIsReady(provider) ? 'Ready' : 'Needs key'; @@ -149,6 +156,75 @@ function providerDraftDirty(provider: ProviderDraft, saved?: LlmProvider) { ); } +/* ─── Section wrapper ─────────────────────────────────────────────────────── */ +function SectionCard({ + icon, + title, + description, + action, + children, +}: { + icon: React.ReactNode; + title: string; + description: string; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ + {icon} + +
+

{title}

+

{description}

+
+
+ {action &&
{action}
} +
+ {children} +
+ ); +} + +/* ─── Field label ─────────────────────────────────────────────────────────── */ +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/* ─── Stat pill ───────────────────────────────────────────────────────────── */ +function StatPill({ label }: { label: string }) { + return ( + + {label} + + ); +} + +/* ─── Provider status badge ───────────────────────────────────────────────── */ +function ProviderBadge({ provider }: { provider: Pick }) { + const ready = providerIsReady(provider); + return ( + + {ready ? : } + {providerStatusLabel(provider)} + + ); +} + export function SettingsPage() { const [providers, setProviders] = useState([]); const [savedProviders, setSavedProviders] = useState([]); @@ -183,6 +259,7 @@ export function SettingsPage() { 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 })), @@ -258,7 +335,6 @@ export function SettingsPage() { const refreshModelCatalog = async ({ quiet = false }: { quiet?: boolean } = {}) => { if (catalogRefreshing) return; - setCatalogRefreshing(true); setSyncErrors([]); const tid = quiet ? null : toast.loading('Refreshing model catalog...'); @@ -278,9 +354,7 @@ export function SettingsPage() { } catch (e) { const msg = e instanceof Error ? e.message : 'Catalog refresh failed'; setSyncErrors([{ providerId: 'catalog-refresh', providerName: 'Model catalog', error: msg }]); - if (!quiet) { - toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg }); - } + if (!quiet) toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg }); } finally { setCatalogRefreshing(false); } @@ -341,6 +415,12 @@ export function SettingsPage() { }; const saveProvider = async (provider: ProviderDraft) => { + if (provider.enabled && !providerHasCredential(provider)) { + setExpandedProviderId(provider.id); + toast.error('Add an API key before enabling this provider.'); + return; + } + setSaving(`provider:${provider.id}`); setError(null); const tid = toast.loading('Saving provider...'); @@ -484,6 +564,7 @@ export function SettingsPage() { 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'; @@ -549,619 +630,582 @@ export function SettingsPage() { !selectedProviderNameExists; const configuredProviderCount = providers.filter(providerIsReady).length; - const customProviderCount = providers.filter(isCustomProvider).length; return ( -
+
{error && ( - {error} + {error} )} {syncErrors.length > 0 && ( -
-

Some provider model catalogs could not refresh.

-

- {syncErrors.map(item => `${item.providerName}: ${item.error}`).join(' | ')} +

+

Some provider catalogs could not refresh

+

+ {syncErrors.map(item => `${item.providerName}: ${item.error}`).join(' · ')}

)} -
-
-
- - - -
-

LLM providers

-

- {configuredProviderCount} configured for model discovery and review routing. -

+ {/* ── LLM Providers ──────────────────────────────────────────────────── */} + } + title="LLM Providers" + description={`${configuredProviderCount} of ${providers.length} configured`} + action={ +
+ + +
+ } + > + {/* Add provider form */} + {addingProvider && ( +
+

New provider

+
+
+ Type + setNewProvider(current => ({ ...current, name: e.target.value }))} + /> + {selectedProviderNameExists && ( +

{newProvider.name.trim()} already exists

+ )} +
+
+ Base URL + setNewProvider(current => ({ ...current, baseUrl: e.target.value }))} + /> +
+
+ API Key + setNewProvider(current => ({ ...current, apiKey: e.target.value }))} + /> +
+
+
+
- -
+ )} + {/* Provider list */} {loading ? (
- + + +
+ ) : providers.length === 0 ? ( +
+ No providers configured yet.
) : ( -
-
-
-
- - {configuredProviderCount} ready - - - {providers.length} providers - - - {customProviderCount} custom - - - {configs.length} models - - - {catalogRefreshing - ? 'Refreshing model lists...' - : catalogRefreshedOnce - ? 'Model lists refreshed this session.' - : 'Loaded from the database.'} - -
- -
+ {/* Row */} +
+ {/* Status dot */} + + + {/* Name + meta */} +
+

{provider.name}

+

+ {formatLabel(provider.apiFormat)} + {modelCount > 0 && · {modelCount} model{modelCount !== 1 ? 's' : ''}} +

+
+ + {/* Credential hint */} +

+ {nativeCloudflare + ? 'Worker binding' + : provider.hasApiKey + ? 'Key saved' + : No key + } +

- {addingProvider && ( -
-
- setNewProvider(current => ({ ...current, name: e.target.value }))} - /> - - -
- {selectedProviderNameExists && ( -

- {newProvider.name.trim()} already exists. -

- )} -
- )} - -
- Provider - Type - Models - Credential - Actions -
-
- {providers.map(provider => { - const nativeCloudflare = provider.apiFormat === 'cloudflare-workers-ai'; - const customProvider = isCustomProvider(provider); - const ready = providerIsReady(provider); - 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; - return ( -
-
-
- - {ready ? : } - -
-

{provider.name}

-
- - {providerStatusLabel(provider)} - - {formatLabel(provider.apiFormat)} - {modelCount} {modelCount === 1 ? 'model' : 'models'} -
+ {/* Expanded edit panel */} + {expanded && ( +
+
+ {customProvider && ( + <> +
+ Name + updateProviderDraft(provider.id, { name: e.target.value })} + />
-
- -

{formatLabel(provider.apiFormat)}

-

{modelCount}

-

- {nativeCloudflare ? 'Worker AI binding' : provider.hasApiKey ? 'Saved key hidden' : 'No API key'} -

- -
- - - - {customProvider && ( - - )} -
-
- - {expanded && ( -
- {customProvider && ( -
- - updateProviderDraft(provider.id, { baseUrl: e.target.value || null })} - /> - -
- )} - - {nativeCloudflare ? ( -
- Native provider. Calls use the Worker AI binding configured in Wrangler. -
- ) : ( - - )} +
+ + )} + {nativeCloudflare ? ( +

+ Native provider — calls use the Worker AI binding configured in Wrangler. +

+ ) : ( +
+ API Key + updateProviderDraft(provider.id, { apiKey: e.target.value })} + className="max-w-md" + />
)} -
- ); - })} -
-
+
+
+ )} + + ); + })}
)} -
- -
-
-
- - - -
-

Global model strategy

-

- Account-wide baseline route and file-size tiers. -

-
+ + {/* Catalog status footer */} + {!loading && ( +
+

+ {catalogRefreshing + ? 'Refreshing model lists…' + : catalogRefreshedOnce + ? 'Model lists refreshed this session.' + : 'Loaded from the database.'} +

+ )} + + + {/* ── Global model strategy ───────────────────────────────────────────── */} + } + title="Global model strategy" + description="Account-wide baseline route and file-size tiers" + action={ -
- -
+ } + > +
{!loading && globalConfig ? ( -
- -
+ ) : ( -
+
- +
)}
-
- -
-
-
- - - -
-

Models and usage limits

-

- Codra model IDs, provider model names, and rate metadata. -

-
+ + + {/* ── Models & Usage Limits ────────────────────────────────────────────── */} + } + title="Models & usage limits" + description={`${configs.length} models · provider mappings and rate limits`} + action={ +
+ {dirtyConfigs.length > 0 && ( + + )} +
- -
- - {loading ? ( -
- {[1, 2, 3].map(i => ( -
- -
- ))} -
- ) : ( -
-
-
- - Add custom model -
-
+ } + > + {/* Add model form */} + {addingModel && ( +
+

New model

+
+
+ Codra model ID setNewModel(current => ({ ...current, modelId: e.target.value }))} /> +
+
+ Provider model name setNewModel(current => ({ ...current, modelName: e.target.value }))} /> - setNewModel(current => ({ ...current, providerId }))} + options={providerOptions} + placeholder="Select provider" + /> +
+
+ {(['rpm', 'rpd', 'tpm'] as const).map(field => ( +
+ {field.toUpperCase()} setNewModel(current => ({ ...current, [field]: Number(e.target.value) || 1 }))} /> - ))} - -
+
+ ))}
+
+ +
+
+ )} -
-
- - setModelSearch(e.target.value)} + /> + +
+ updateModel(cfg.modelId, { providerId })} - options={providerOptions} - /> -
+
+ )} + + ); + })} - {filteredConfigs.length === 0 && ( -
- No models match the current filters. -
- )} + {filteredConfigs.length === 0 && ( +
+ No models match the current filters.
-
+ )}
)} -
+
); } diff --git a/src/server/core/github.ts b/src/server/core/github.ts index 7d0ea18..1e2d8df 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -87,6 +87,10 @@ export type GitHubReviewComment = { body: string; }; +type GitHubIssueLabel = { + name?: string; +}; + function installationCacheKey(installationId: string) { return `install:${installationId}`; } @@ -583,6 +587,28 @@ export class GitHubClient { }); } + async listIssueLabels(owner: string, repo: string, issueNumber: number) { + return withRetry(`listIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { + const response = await this.requestAndCheck(`/repos/${owner}/${repo}/issues/${issueNumber}/labels?per_page=100`); + const labels = (await response.json()) as GitHubIssueLabel[]; + return labels + .map(label => label.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + }); + } + + async removeIssueLabelsIfPresent(owner: string, repo: string, issueNumber: number, labels: string[]) { + const currentLabels = await this.listIssueLabels(owner, repo, issueNumber); + const currentByLowerName = new Map(currentLabels.map(label => [label.toLowerCase(), label])); + + for (const label of labels) { + const currentLabel = currentByLowerName.get(label.toLowerCase()); + if (currentLabel) { + await this.removeIssueLabel(owner, repo, issueNumber, currentLabel); + } + } + } + async removeIssueLabel(owner: string, repo: string, issueNumber: number, label: string) { return withRetry(`removeIssueLabel ${owner}/${repo}#${issueNumber} ${label}`, async () => { const response = await this.request( diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 36f2867..d55f275 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -298,9 +298,12 @@ async function resolveQueuedJob( if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { const labels = repoConfig.parsedJson.review.labels; const gh = new GitHubClient(env, installationId); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p1); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p2); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p3); + await gh.removeIssueLabelsIfPresent( + prPayload.repository.owner.login, + prPayload.repository.name, + prPayload.pull_request.number, + [labels.p1, labels.p2, labels.p3], + ); } } return null; @@ -699,11 +702,12 @@ async function runFinalizePhase( } as const; const label = labelMap[verdictSummary.verdict]; - for (const possibleLabel of [labels.p1, labels.p2, labels.p3]) { - if (possibleLabel !== label.name) { - await github.removeIssueLabel(job.owner, job.repo, job.prNumber, possibleLabel); - } - } + await github.removeIssueLabelsIfPresent( + job.owner, + job.repo, + job.prNumber, + [labels.p1, labels.p2, labels.p3].filter(possibleLabel => possibleLabel !== label.name), + ); await github.ensureLabel(job.owner, job.repo, label.name, label.color); await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index 68e41f0..fe62104 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -6,6 +6,7 @@ import { deleteLlmProvider, deleteModelConfig, findLlmProviderByName, + getLlmProvider, getResolvedModelConfig, listLlmProviderSecrets, listLlmProviders, @@ -107,6 +108,10 @@ function providerErrorStatus(error: ProviderRequestError) { return error.status >= 500 ? 502 : error.status; } +function providerCanBeEnabled(apiFormat: z.infer, encryptedApiKey: string | null | undefined) { + return apiFormat === 'cloudflare-workers-ai' || Boolean(encryptedApiKey); +} + function optionalEnv(value: () => string) { try { const resolved = value().trim(); @@ -217,6 +222,10 @@ export function createModelsRouter() { throw error; } + if (input.enabled && !providerCanBeEnabled(input.apiFormat, encryptedApiKey)) { + return jsonError(`Provider ${input.name} needs an API key before it can be enabled.`, 400); + } + let provider; try { provider = await createLlmProvider(c.env, { @@ -248,6 +257,9 @@ export function createModelsRouter() { } const input = parsed.data; + const existing = await getLlmProvider(c.env, id); + if (!existing) return jsonError('Provider not found.', 404); + let encryptedApiKey: string | null | undefined; try { encryptedApiKey = input.apiFormat === 'cloudflare-workers-ai' @@ -260,6 +272,13 @@ export function createModelsRouter() { throw error; } + const effectiveEncryptedApiKey = encryptedApiKey !== undefined + ? encryptedApiKey + : existing.encryptedApiKey; + if (input.enabled && !providerCanBeEnabled(input.apiFormat, effectiveEncryptedApiKey)) { + return jsonError(`Provider ${input.name} needs an API key before it can be enabled.`, 400); + } + let provider; try { provider = await updateLlmProvider(c.env, id, { diff --git a/src/server/services/github.ts b/src/server/services/github.ts index d7c2792..f8e9150 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -36,8 +36,11 @@ export class GitHubService { return this.client.addIssueLabels(owner, repo, prNumber, labels); } + async removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]) { + return this.client.removeIssueLabelsIfPresent(owner, repo, prNumber, labels); + } + async removeIssueLabel(owner: string, repo: string, prNumber: number, label: string) { return this.client.removeIssueLabel(owner, repo, prNumber, label); } } - diff --git a/test/api.spec.ts b/test/api.spec.ts index 1d2fe94..eda857e 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -400,6 +400,60 @@ describe('Dashboard API Suite', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('rejects enabling non-Cloudflare providers without a saved API key', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + + const createResponse = await app.request('/api/models/providers', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: 'No Key Provider', + apiFormat: 'openai', + baseUrl: 'https://api.example.com/v1', + enabled: true, + }), + }, env); + expect(createResponse.status).toBe(400); + + const disabledCreateResponse = await app.request('/api/models/providers', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: 'Disabled No Key Provider', + apiFormat: 'openai', + baseUrl: 'https://api.example.com/v1', + enabled: false, + }), + }, env); + expect(disabledCreateResponse.status).toBe(201); + const { provider } = await disabledCreateResponse.json() as { provider: { id: string; name: string; apiFormat: string; baseUrl: string } }; + + const updateResponse = await app.request(`/api/models/providers/${provider.id}`, { + method: 'PATCH', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: provider.name, + apiFormat: provider.apiFormat, + baseUrl: provider.baseUrl, + enabled: true, + }), + }, env); + expect(updateResponse.status).toBe(400); + }); + it('refreshes provider model catalogs on the explicit sync endpoint', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index dece307..4e70657 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -28,6 +28,7 @@ vi.mock('@server/services/github', () => { async createReview() { return { id: 456 }; } async ensureLabel() { return {}; } async addIssueLabels() { return {}; } + async removeIssueLabelsIfPresent() { return {}; } async removeIssueLabel() { return {}; } } return { GitHubService: MockGitHubService }; From 0293b366dd9c7f795ccbfe420a7eea928f5be3ae Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 20:03:13 +0530 Subject: [PATCH 15/32] add: consolidate LLM provider management and make rate limits optional --- db/migrations/001_initial.sql | 1110 ++++++++++------- db/migrations/002_llm.sql | 144 --- scripts/migrate.mjs | 9 + .../features/models/model-chain.tsx | 23 +- src/client/pages/repos.tsx | 37 +- src/client/pages/settings.tsx | 65 +- src/server/core/config.ts | 21 +- src/server/core/review.ts | 6 +- src/server/db/model-configs.ts | 18 +- src/server/routes/api/models.ts | 9 +- src/server/services/model.ts | 22 +- src/shared/schema.ts | 20 +- test/api.spec.ts | 8 +- test/model-service.spec.ts | 9 + test/settings.spec.ts | 8 + 15 files changed, 766 insertions(+), 743 deletions(-) delete mode 100644 db/migrations/002_llm.sql diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index 4219560..a3232de 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -1,170 +1,170 @@ -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -DO $$ BEGIN - CREATE TYPE job_trigger AS ENUM ('auto', 'mention', 'retry'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE job_status AS ENUM ('queued', 'running', 'done', 'failed', 'superseded'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE job_verdict AS ENUM ('approve', 'comment'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE file_status_enum AS ENUM ('pending', 'done', 'skipped', 'failed'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -CREATE TABLE IF NOT EXISTS repositories ( - installation_id BIGINT NOT NULL, - id SERIAL PRIMARY KEY, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - UNIQUE(owner, repo) -); -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); - -CREATE TABLE IF NOT EXISTS jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - retry_of_job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, - - check_run_id BIGINT, - review_id BIGINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - started_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ, - - repository_id INTEGER NOT NULL REFERENCES repositories(id), - pr_number INTEGER NOT NULL, - total_input_tokens INTEGER DEFAULT 0, - total_output_tokens INTEGER DEFAULT 0, - file_count INTEGER DEFAULT 0, - comment_count INTEGER DEFAULT 0, - overall_confidence_score REAL, - - commit_sha BYTEA NOT NULL, - base_sha BYTEA NOT NULL, - - trigger TEXT NOT NULL CHECK (trigger IN ('auto', 'mention', 'retry')), - status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded')), - verdict TEXT CHECK (verdict IN ('approve', 'comment')), - pr_title TEXT, - pr_author TEXT, - head_ref TEXT, - base_ref TEXT, - summary_model TEXT, - overall_correctness TEXT, - error_msg TEXT, - summary_markdown TEXT, - config_snapshot JSONB COMPRESSION lz4, - steps JSONB COMPRESSION lz4 DEFAULT '[]'::jsonb -) WITH (fillfactor = 90); - -CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); -CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); -CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); -CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); -CREATE INDEX IF NOT EXISTS jobs_correctness_idx ON jobs (overall_correctness); - -CREATE TABLE IF NOT EXISTS file_reviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - diff_line_count INTEGER, - input_tokens INTEGER, - output_tokens INTEGER, - duration_ms INTEGER, - confidence_score REAL, - - file_status TEXT NOT NULL CHECK (file_status IN ('pending', 'done', 'skipped', 'failed')), - verdict TEXT CHECK (verdict IN ('approve', 'comment')), - file_path TEXT NOT NULL, - model_used TEXT NOT NULL, - model_provider TEXT, - overall_correctness TEXT, - file_summary TEXT, - error_msg TEXT, - diff_input TEXT COMPRESSION lz4, - raw_ai_output TEXT COMPRESSION lz4 -) WITH (fillfactor = 90); - -CREATE INDEX IF NOT EXISTS file_reviews_job_idx ON file_reviews (job_id); -CREATE INDEX IF NOT EXISTS file_reviews_correctness_idx ON file_reviews (overall_correctness); -CREATE INDEX IF NOT EXISTS file_reviews_provider_idx ON file_reviews (model_provider); - -CREATE TABLE IF NOT EXISTS review_comments ( - file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, - id BIGSERIAL PRIMARY KEY, - line INTEGER, - position INTEGER, - path TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'quality', - title TEXT NOT NULL, - body TEXT COMPRESSION lz4 NOT NULL, - code_suggestion TEXT COMPRESSION lz4 -); -CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); - -CREATE TABLE IF NOT EXISTS repo_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - repository_id INTEGER NOT NULL REFERENCES repositories(id), - - enabled BOOLEAN NOT NULL DEFAULT TRUE, - - main_model TEXT, - parsed_json JSONB, - fallback_models JSONB DEFAULT '[]'::jsonb, - size_overrides JSONB, - UNIQUE (repository_id) -); - -CREATE TABLE IF NOT EXISTS webhook_deliveries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - received_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - repository_id INTEGER REFERENCES repositories(id), - - delivery_id TEXT NOT NULL UNIQUE, - event_name TEXT NOT NULL, - payload JSONB COMPRESSION lz4 NOT NULL -); - -CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); - -CREATE TABLE IF NOT EXISTS model_configs ( - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - - rpm INTEGER NOT NULL, - tpm INTEGER NOT NULL, - rpd INTEGER NOT NULL, - - model_id TEXT PRIMARY KEY, - provider TEXT NOT NULL -); - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) -VALUES - ('gemma-4-31b-it', 15, 1000000, 1500, 'google'), - ('gemma-4-26b-a4b-it', 30, 1000000, 1500, 'google'), - ('@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare'), - ('@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare') +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ BEGIN + CREATE TYPE job_trigger AS ENUM ('auto', 'mention', 'retry'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE job_status AS ENUM ('queued', 'running', 'done', 'failed', 'superseded'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE job_verdict AS ENUM ('approve', 'comment'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE file_status_enum AS ENUM ('pending', 'done', 'skipped', 'failed'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS repositories ( + installation_id BIGINT NOT NULL, + id SERIAL PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + UNIQUE(owner, repo) +); +CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); + +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retry_of_job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, + + check_run_id BIGINT, + review_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + + repository_id INTEGER NOT NULL REFERENCES repositories(id), + pr_number INTEGER NOT NULL, + total_input_tokens INTEGER DEFAULT 0, + total_output_tokens INTEGER DEFAULT 0, + file_count INTEGER DEFAULT 0, + comment_count INTEGER DEFAULT 0, + overall_confidence_score REAL, + + commit_sha BYTEA NOT NULL, + base_sha BYTEA NOT NULL, + + trigger TEXT NOT NULL CHECK (trigger IN ('auto', 'mention', 'retry')), + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded')), + verdict TEXT CHECK (verdict IN ('approve', 'comment')), + pr_title TEXT, + pr_author TEXT, + head_ref TEXT, + base_ref TEXT, + summary_model TEXT, + overall_correctness TEXT, + error_msg TEXT, + summary_markdown TEXT, + config_snapshot JSONB COMPRESSION lz4, + steps JSONB COMPRESSION lz4 DEFAULT '[]'::jsonb +) WITH (fillfactor = 90); + +CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); +CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); +CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); +CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); +CREATE INDEX IF NOT EXISTS jobs_correctness_idx ON jobs (overall_correctness); + +CREATE TABLE IF NOT EXISTS file_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + diff_line_count INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + duration_ms INTEGER, + confidence_score REAL, + + file_status TEXT NOT NULL CHECK (file_status IN ('pending', 'done', 'skipped', 'failed')), + verdict TEXT CHECK (verdict IN ('approve', 'comment')), + file_path TEXT NOT NULL, + model_used TEXT NOT NULL, + model_provider TEXT, + overall_correctness TEXT, + file_summary TEXT, + error_msg TEXT, + diff_input TEXT COMPRESSION lz4, + raw_ai_output TEXT COMPRESSION lz4 +) WITH (fillfactor = 90); + +CREATE INDEX IF NOT EXISTS file_reviews_job_idx ON file_reviews (job_id); +CREATE INDEX IF NOT EXISTS file_reviews_correctness_idx ON file_reviews (overall_correctness); +CREATE INDEX IF NOT EXISTS file_reviews_provider_idx ON file_reviews (model_provider); + +CREATE TABLE IF NOT EXISTS review_comments ( + file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, + id BIGSERIAL PRIMARY KEY, + line INTEGER, + position INTEGER, + path TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'quality', + title TEXT NOT NULL, + body TEXT COMPRESSION lz4 NOT NULL, + code_suggestion TEXT COMPRESSION lz4 +); +CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); + +CREATE TABLE IF NOT EXISTS repo_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + repository_id INTEGER NOT NULL REFERENCES repositories(id), + + enabled BOOLEAN NOT NULL DEFAULT TRUE, + + main_model TEXT, + parsed_json JSONB, + fallback_models JSONB DEFAULT '[]'::jsonb, + size_overrides JSONB, + UNIQUE (repository_id) +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + repository_id INTEGER REFERENCES repositories(id), + + delivery_id TEXT NOT NULL UNIQUE, + event_name TEXT NOT NULL, + payload JSONB COMPRESSION lz4 NOT NULL +); + +CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); + +CREATE TABLE IF NOT EXISTS model_configs ( + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + + rpm INTEGER, + tpm INTEGER, + rpd INTEGER, + + model_id TEXT PRIMARY KEY, + provider TEXT NOT NULL +); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) +VALUES + ('gemma-4-31b-it', 15, 1000000, 1500, 'google'), + ('gemma-4-26b-a4b-it', 30, 1000000, 1500, 'google'), + ('@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare'), + ('@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare') ON CONFLICT (model_id) DO UPDATE SET rpm = EXCLUDED.rpm, tpm = EXCLUDED.tpm, @@ -220,315 +220,467 @@ WHERE main_model = '@cf/moonshotai/kimi-k2.5' OR parsed_json::text LIKE '%@cf/moonshotai/kimi-k2.5%'; CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE IF NOT EXISTS repositories ( - installation_id BIGINT NOT NULL, - id SERIAL PRIMARY KEY, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - UNIQUE(owner, repo) -); - -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); - -CREATE TABLE IF NOT EXISTS review_comments ( - file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, - id BIGSERIAL PRIMARY KEY, - line INTEGER, - position INTEGER, - path TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'quality', - title TEXT NOT NULL, - body TEXT COMPRESSION lz4 NOT NULL, - code_suggestion TEXT COMPRESSION lz4 -); - -CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); - -DO $$ -DECLARE - has_old_job_repo_columns BOOLEAN; - has_old_repo_config_columns BOOLEAN; - has_old_webhook_repo_columns BOOLEAN; - commit_sha_type TEXT; - base_sha_type TEXT; - null_repository_jobs INTEGER; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'jobs' - AND column_name IN ('installation_id', 'owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 3 - ) INTO has_old_job_repo_columns; - - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'repo_configs' - AND column_name IN ('installation_id', 'owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 3 - ) INTO has_old_repo_config_columns; - - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'webhook_deliveries' - AND column_name IN ('owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 2 - ) INTO has_old_webhook_repo_columns; - - IF has_old_job_repo_columns THEN - EXECUTE ' - INSERT INTO repositories (installation_id, owner, repo) - SELECT DISTINCT - CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, - owner, - repo - FROM jobs - WHERE installation_id IS NOT NULL - AND owner IS NOT NULL - AND repo IS NOT NULL - ON CONFLICT (owner, repo) DO UPDATE - SET installation_id = EXCLUDED.installation_id - '; - END IF; - - IF has_old_repo_config_columns THEN - EXECUTE ' - INSERT INTO repositories (installation_id, owner, repo) - SELECT DISTINCT - CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, - owner, - repo - FROM repo_configs - WHERE installation_id IS NOT NULL - AND owner IS NOT NULL - AND repo IS NOT NULL - ON CONFLICT (owner, repo) DO UPDATE - SET installation_id = EXCLUDED.installation_id - '; - END IF; - - ALTER TABLE jobs ADD COLUMN IF NOT EXISTS repository_id INTEGER; - - IF has_old_job_repo_columns THEN - EXECUTE ' - UPDATE jobs j - SET repository_id = r.id - FROM repositories r - WHERE j.repository_id IS NULL - AND r.owner = j.owner - AND r.repo = j.repo - '; - END IF; - - SELECT data_type - INTO commit_sha_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'jobs' - AND column_name = 'commit_sha'; - - IF commit_sha_type IS NOT NULL AND commit_sha_type <> 'bytea' THEN - ALTER TABLE jobs ADD COLUMN IF NOT EXISTS commit_sha_bytea BYTEA; - EXECUTE ' - UPDATE jobs - SET commit_sha_bytea = CASE - WHEN commit_sha ~ ''^[0-9a-fA-F]+$'' AND length(commit_sha) % 2 = 0 THEN decode(commit_sha, ''hex'') - ELSE convert_to(commit_sha, ''UTF8'') - END - WHERE commit_sha_bytea IS NULL - '; - ALTER TABLE jobs DROP COLUMN commit_sha; - ALTER TABLE jobs RENAME COLUMN commit_sha_bytea TO commit_sha; - END IF; - - SELECT data_type - INTO base_sha_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'jobs' - AND column_name = 'base_sha'; - - IF base_sha_type IS NOT NULL AND base_sha_type <> 'bytea' THEN - ALTER TABLE jobs ADD COLUMN IF NOT EXISTS base_sha_bytea BYTEA; - EXECUTE ' - UPDATE jobs - SET base_sha_bytea = CASE - WHEN base_sha ~ ''^[0-9a-fA-F]+$'' AND length(base_sha) % 2 = 0 THEN decode(base_sha, ''hex'') - ELSE convert_to(base_sha, ''UTF8'') - END - WHERE base_sha_bytea IS NULL - '; - ALTER TABLE jobs DROP COLUMN base_sha; - ALTER TABLE jobs RENAME COLUMN base_sha_bytea TO base_sha; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'jobs_repository_id_fkey' - ) THEN - ALTER TABLE jobs - ADD CONSTRAINT jobs_repository_id_fkey - FOREIGN KEY (repository_id) REFERENCES repositories(id); - END IF; - - SELECT COUNT(*) INTO null_repository_jobs FROM jobs WHERE repository_id IS NULL; - IF null_repository_jobs = 0 THEN - ALTER TABLE jobs ALTER COLUMN repository_id SET NOT NULL; - END IF; - - DROP INDEX IF EXISTS jobs_repo_idx; - DROP INDEX IF EXISTS jobs_status_idx; - DROP INDEX IF EXISTS jobs_created_idx; - DROP INDEX IF EXISTS jobs_head_sha_idx; - - CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); - CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); - CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); - CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); - - IF has_old_job_repo_columns THEN - ALTER TABLE jobs DROP COLUMN IF EXISTS installation_id; - ALTER TABLE jobs DROP COLUMN IF EXISTS owner; - ALTER TABLE jobs DROP COLUMN IF EXISTS repo; - END IF; -END $$; - -DO $$ -DECLARE - has_old_columns BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'repo_configs' - AND column_name IN ('installation_id', 'owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 3 - ) INTO has_old_columns; - - ALTER TABLE repo_configs ADD COLUMN IF NOT EXISTS repository_id INTEGER; - - IF has_old_columns THEN - EXECUTE ' - UPDATE repo_configs rc - SET repository_id = r.id - FROM repositories r - WHERE rc.repository_id IS NULL - AND r.owner = rc.owner - AND r.repo = rc.repo - '; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_fkey' - ) THEN - ALTER TABLE repo_configs - ADD CONSTRAINT repo_configs_repository_id_fkey - FOREIGN KEY (repository_id) REFERENCES repositories(id); - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_key' - ) THEN - ALTER TABLE repo_configs ADD CONSTRAINT repo_configs_repository_id_key UNIQUE (repository_id); - END IF; - - ALTER TABLE repo_configs DROP CONSTRAINT IF EXISTS repo_configs_owner_repo_key; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS installation_id; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS owner; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS repo; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS raw_yaml; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS config_missing; -END $$; - -DO $$ -DECLARE - has_old_columns BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'webhook_deliveries' - AND column_name IN ('owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 2 - ) INTO has_old_columns; - - ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS repository_id INTEGER; - - IF has_old_columns THEN - EXECUTE ' - UPDATE webhook_deliveries wd - SET repository_id = r.id - FROM repositories r - WHERE wd.repository_id IS NULL - AND r.owner = wd.owner - AND r.repo = wd.repo - '; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'webhook_deliveries_repository_id_fkey' - ) THEN - ALTER TABLE webhook_deliveries - ADD CONSTRAINT webhook_deliveries_repository_id_fkey - FOREIGN KEY (repository_id) REFERENCES repositories(id); - END IF; - - DROP INDEX IF EXISTS webhook_deliveries_repo_idx; - CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); - - ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS owner; - ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS repo; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'file_reviews' - AND column_name = 'parsed_comments' - ) THEN - INSERT INTO review_comments ( - file_review_id, - path, - line, - position, - severity, - category, - title, - body, - code_suggestion - ) - SELECT - fr.id, - COALESCE(comment->>'path', fr.file_path), - NULLIF(comment->>'line', '')::int, - NULLIF(comment->>'position', '')::int, - COALESCE(comment->>'severity', 'P3'), - COALESCE(comment->>'category', 'quality'), - COALESCE(comment->>'title', 'Code finding'), - COALESCE(comment->>'body', ''), - comment->>'codeSuggestion' - FROM file_reviews fr - CROSS JOIN LATERAL jsonb_array_elements(COALESCE(fr.parsed_comments, '[]'::jsonb)) AS comment - WHERE NOT EXISTS ( - SELECT 1 FROM review_comments rc WHERE rc.file_review_id = fr.id - ); - - ALTER TABLE file_reviews DROP COLUMN parsed_comments; - END IF; -END $$; + +CREATE TABLE IF NOT EXISTS repositories ( + installation_id BIGINT NOT NULL, + id SERIAL PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + UNIQUE(owner, repo) +); + +CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); + +CREATE TABLE IF NOT EXISTS review_comments ( + file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, + id BIGSERIAL PRIMARY KEY, + line INTEGER, + position INTEGER, + path TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'quality', + title TEXT NOT NULL, + body TEXT COMPRESSION lz4 NOT NULL, + code_suggestion TEXT COMPRESSION lz4 +); + +CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); + +DO $$ +DECLARE + has_old_job_repo_columns BOOLEAN; + has_old_repo_config_columns BOOLEAN; + has_old_webhook_repo_columns BOOLEAN; + commit_sha_type TEXT; + base_sha_type TEXT; + null_repository_jobs INTEGER; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'jobs' + AND column_name IN ('installation_id', 'owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 3 + ) INTO has_old_job_repo_columns; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'repo_configs' + AND column_name IN ('installation_id', 'owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 3 + ) INTO has_old_repo_config_columns; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_deliveries' + AND column_name IN ('owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 2 + ) INTO has_old_webhook_repo_columns; + + IF has_old_job_repo_columns THEN + EXECUTE ' + INSERT INTO repositories (installation_id, owner, repo) + SELECT DISTINCT + CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, + owner, + repo + FROM jobs + WHERE installation_id IS NOT NULL + AND owner IS NOT NULL + AND repo IS NOT NULL + ON CONFLICT (owner, repo) DO UPDATE + SET installation_id = EXCLUDED.installation_id + '; + END IF; + + IF has_old_repo_config_columns THEN + EXECUTE ' + INSERT INTO repositories (installation_id, owner, repo) + SELECT DISTINCT + CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, + owner, + repo + FROM repo_configs + WHERE installation_id IS NOT NULL + AND owner IS NOT NULL + AND repo IS NOT NULL + ON CONFLICT (owner, repo) DO UPDATE + SET installation_id = EXCLUDED.installation_id + '; + END IF; + + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS repository_id INTEGER; + + IF has_old_job_repo_columns THEN + EXECUTE ' + UPDATE jobs j + SET repository_id = r.id + FROM repositories r + WHERE j.repository_id IS NULL + AND r.owner = j.owner + AND r.repo = j.repo + '; + END IF; + + SELECT data_type + INTO commit_sha_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'jobs' + AND column_name = 'commit_sha'; + + IF commit_sha_type IS NOT NULL AND commit_sha_type <> 'bytea' THEN + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS commit_sha_bytea BYTEA; + EXECUTE ' + UPDATE jobs + SET commit_sha_bytea = CASE + WHEN commit_sha ~ ''^[0-9a-fA-F]+$'' AND length(commit_sha) % 2 = 0 THEN decode(commit_sha, ''hex'') + ELSE convert_to(commit_sha, ''UTF8'') + END + WHERE commit_sha_bytea IS NULL + '; + ALTER TABLE jobs DROP COLUMN commit_sha; + ALTER TABLE jobs RENAME COLUMN commit_sha_bytea TO commit_sha; + END IF; + + SELECT data_type + INTO base_sha_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'jobs' + AND column_name = 'base_sha'; + + IF base_sha_type IS NOT NULL AND base_sha_type <> 'bytea' THEN + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS base_sha_bytea BYTEA; + EXECUTE ' + UPDATE jobs + SET base_sha_bytea = CASE + WHEN base_sha ~ ''^[0-9a-fA-F]+$'' AND length(base_sha) % 2 = 0 THEN decode(base_sha, ''hex'') + ELSE convert_to(base_sha, ''UTF8'') + END + WHERE base_sha_bytea IS NULL + '; + ALTER TABLE jobs DROP COLUMN base_sha; + ALTER TABLE jobs RENAME COLUMN base_sha_bytea TO base_sha; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'jobs_repository_id_fkey' + ) THEN + ALTER TABLE jobs + ADD CONSTRAINT jobs_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); + END IF; + + SELECT COUNT(*) INTO null_repository_jobs FROM jobs WHERE repository_id IS NULL; + IF null_repository_jobs = 0 THEN + ALTER TABLE jobs ALTER COLUMN repository_id SET NOT NULL; + END IF; + + DROP INDEX IF EXISTS jobs_repo_idx; + DROP INDEX IF EXISTS jobs_status_idx; + DROP INDEX IF EXISTS jobs_created_idx; + DROP INDEX IF EXISTS jobs_head_sha_idx; + + CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); + CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); + CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); + CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); + + IF has_old_job_repo_columns THEN + ALTER TABLE jobs DROP COLUMN IF EXISTS installation_id; + ALTER TABLE jobs DROP COLUMN IF EXISTS owner; + ALTER TABLE jobs DROP COLUMN IF EXISTS repo; + END IF; +END $$; + +DO $$ +DECLARE + has_old_columns BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'repo_configs' + AND column_name IN ('installation_id', 'owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 3 + ) INTO has_old_columns; + + ALTER TABLE repo_configs ADD COLUMN IF NOT EXISTS repository_id INTEGER; + + IF has_old_columns THEN + EXECUTE ' + UPDATE repo_configs rc + SET repository_id = r.id + FROM repositories r + WHERE rc.repository_id IS NULL + AND r.owner = rc.owner + AND r.repo = rc.repo + '; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_fkey' + ) THEN + ALTER TABLE repo_configs + ADD CONSTRAINT repo_configs_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_key' + ) THEN + ALTER TABLE repo_configs ADD CONSTRAINT repo_configs_repository_id_key UNIQUE (repository_id); + END IF; + + ALTER TABLE repo_configs DROP CONSTRAINT IF EXISTS repo_configs_owner_repo_key; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS installation_id; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS owner; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS repo; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS raw_yaml; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS config_missing; +END $$; + +DO $$ +DECLARE + has_old_columns BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_deliveries' + AND column_name IN ('owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 2 + ) INTO has_old_columns; + + ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS repository_id INTEGER; + + IF has_old_columns THEN + EXECUTE ' + UPDATE webhook_deliveries wd + SET repository_id = r.id + FROM repositories r + WHERE wd.repository_id IS NULL + AND r.owner = wd.owner + AND r.repo = wd.repo + '; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'webhook_deliveries_repository_id_fkey' + ) THEN + ALTER TABLE webhook_deliveries + ADD CONSTRAINT webhook_deliveries_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); + END IF; + + DROP INDEX IF EXISTS webhook_deliveries_repo_idx; + CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); + + ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS owner; + ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS repo; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'file_reviews' + AND column_name = 'parsed_comments' + ) THEN + INSERT INTO review_comments ( + file_review_id, + path, + line, + position, + severity, + category, + title, + body, + code_suggestion + ) + SELECT + fr.id, + COALESCE(comment->>'path', fr.file_path), + NULLIF(comment->>'line', '')::int, + NULLIF(comment->>'position', '')::int, + COALESCE(comment->>'severity', 'P3'), + COALESCE(comment->>'category', 'quality'), + COALESCE(comment->>'title', 'Code finding'), + COALESCE(comment->>'body', ''), + comment->>'codeSuggestion' + FROM file_reviews fr + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(fr.parsed_comments, '[]'::jsonb)) AS comment + WHERE NOT EXISTS ( + SELECT 1 FROM review_comments rc WHERE rc.file_review_id = fr.id + ); + + ALTER TABLE file_reviews DROP COLUMN parsed_comments; + END IF; +END $$; + +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx + ON jobs (lease_expires_at) + WHERE status = 'running' AND lease_expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx + ON jobs (status, check_run_completed_at) + WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; + +CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx + ON jobs (last_queue_message_at, heartbeat_at) + WHERE status = 'running' AND lease_expires_at IS NULL; + +DELETE FROM file_reviews fr +USING ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number + FROM file_reviews +) ranked +WHERE fr.id = ranked.id + AND ranked.row_number > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key + ON file_reviews (job_id, file_path); + +CREATE TABLE IF NOT EXISTS llm_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), + base_url TEXT, + encrypted_api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +UPDATE llm_providers +SET name = 'Cloudflare', updated_at = now() +WHERE name = 'Cloudflare Workers AI'; + +UPDATE llm_providers +SET name = 'Google', updated_at = now() +WHERE name = 'Google Gemini'; + +INSERT INTO llm_providers (name, api_format, base_url, enabled) +VALUES + ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), + ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), + ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), + ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), + ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) +ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now(); + +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID; +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT; + +UPDATE model_configs mc +SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id) +FROM llm_providers provider_record +WHERE mc.provider_id IS NULL + AND ( + (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') + OR (mc.provider = 'gemini' AND provider_record.name = 'Google') + OR (mc.provider = 'google' AND provider_record.name = 'Google') + OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') + OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + ); + +UPDATE model_configs +SET model_name = model_id +WHERE model_name IS NULL; + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT '@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare', p.id, '@cf/moonshotai/kimi-k2.6', 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, + updated_at = now(); + +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() +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, + updated_at = now(); + +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() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +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() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL; +ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL; +ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL; +ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL; +ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL; + +UPDATE model_configs +SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now() +WHERE rpm = 1 AND tpm = 1 AND rpd = 1; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' + ) THEN + ALTER TABLE model_configs + ADD CONSTRAINT model_configs_provider_id_fkey + FOREIGN KEY (provider_id) REFERENCES llm_providers(id); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id); diff --git a/db/migrations/002_llm.sql b/db/migrations/002_llm.sql deleted file mode 100644 index 9921c5c..0000000 --- a/db/migrations/002_llm.sql +++ /dev/null @@ -1,144 +0,0 @@ -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; -ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx - ON jobs (lease_expires_at) - WHERE status = 'running' AND lease_expires_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx - ON jobs (status, check_run_completed_at) - WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; - -CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx - ON jobs (last_queue_message_at, heartbeat_at) - WHERE status = 'running' AND lease_expires_at IS NULL; - -DELETE FROM file_reviews fr -USING ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number - FROM file_reviews -) ranked -WHERE fr.id = ranked.id - AND ranked.row_number > 1; - -CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key - ON file_reviews (job_id, file_path); - -CREATE TABLE IF NOT EXISTS llm_providers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), - base_url TEXT, - encrypted_api_key TEXT, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -UPDATE llm_providers -SET name = 'Cloudflare', updated_at = now() -WHERE name = 'Cloudflare Workers AI'; - -UPDATE llm_providers -SET name = 'Google', updated_at = now() -WHERE name = 'Google Gemini'; - -INSERT INTO llm_providers (name, api_format, base_url, enabled) -VALUES - ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), - ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), - ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), - ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), - ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) -ON CONFLICT (name) DO UPDATE SET - api_format = EXCLUDED.api_format, - base_url = EXCLUDED.base_url, - updated_at = now(); - -ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID; -ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT; - -UPDATE model_configs mc -SET - provider_id = provider_record.id, - model_name = COALESCE(mc.model_name, mc.model_id) -FROM llm_providers provider_record -WHERE mc.provider_id IS NULL - AND ( - (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') - OR (mc.provider = 'gemini' AND provider_record.name = 'Google') - OR (mc.provider = 'google' AND provider_record.name = 'Google') - OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') - OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') - ); - -UPDATE model_configs -SET model_name = model_id -WHERE model_name IS NULL; - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) -SELECT '@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare', p.id, '@cf/moonshotai/kimi-k2.6', 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, - updated_at = now(); - -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() -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, - updated_at = now(); - -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() -FROM llm_providers p -WHERE p.name = 'Google' -ON CONFLICT (model_id) DO UPDATE SET - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - updated_at = now(); - -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() -FROM llm_providers p -WHERE p.name = 'Google' -ON CONFLICT (model_id) DO UPDATE SET - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - updated_at = now(); - -ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL; -ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' - ) THEN - ALTER TABLE model_configs - ADD CONSTRAINT model_configs_provider_id_fkey - FOREIGN KEY (provider_id) REFERENCES llm_providers(id); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index b3e7214..3cf65bd 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -229,6 +229,15 @@ async function ensureModelCatalog() { return; } + 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( ` INSERT INTO llm_providers (name, api_format, base_url, enabled) diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index cb27b60..4775737 100644 --- a/src/client/components/features/models/model-chain.tsx +++ b/src/client/components/features/models/model-chain.tsx @@ -24,7 +24,7 @@ export type ModelRouteTier = { }; export type ModelRouteConfig = { - main: string; + main: string | null; fallbacks: string[]; size_overrides: ModelRouteTier[]; }; @@ -38,17 +38,21 @@ export function getModelLabel(model: string, models: ModelOption[] = []) { } export function describeModelRoute(config: ModelRouteConfig, models: ModelOption[] = []) { + if (!config.main && (config.fallbacks?.length ?? 0) === 0 && (config.size_overrides?.length ?? 0) === 0) { + return 'No model strategy configured'; + } + const fallbacks = config.fallbacks?.length ?? 0; const tiers = config.size_overrides?.length ?? 0; return [ - getModelLabel(config.main, models), + config.main ? getModelLabel(config.main, models) : 'No baseline model', fallbacks > 0 ? `${fallbacks} fallback${fallbacks === 1 ? '' : 's'}` : 'no fallbacks', tiers > 0 ? `${tiers} tier${tiers === 1 ? '' : 's'}` : 'baseline only', ].join(' · '); } interface ModelSelectorProps { - value: string; + value: string | null; onValueChange: (value: string) => void; models: ModelOption[]; providers: ProviderOption[]; @@ -66,7 +70,7 @@ export function ModelSelector({ density = 'comfortable', className, }: ModelSelectorProps) { - const currentModel = models.find(m => m.value === value) || models[0]; + const currentModel = models.find(m => m.value === value); const [provider, setProvider] = useState(currentModel?.providerId ?? providers[0]?.value ?? ''); useEffect(() => { @@ -112,9 +116,10 @@ export function ModelSelector({ /> setNewModel(current => ({ ...current, [field]: Number(e.target.value) || 1 }))} + value={newModel[field] ?? ''} + placeholder="None" + onChange={e => setNewModel(current => ({ ...current, [field]: parseOptionalLimit(e.target.value) }))} />
))} @@ -1111,7 +1111,7 @@ export function SettingsPage() {
{(['rpm', 'rpd', 'tpm'] as const).map(field => ( - {field.toUpperCase()} {cfg[field].toLocaleString()} + {field.toUpperCase()} {formatOptionalLimit(cfg[field])} ))}
@@ -1185,8 +1185,9 @@ export function SettingsPage() { updateQuota(cfg.modelId, field, Number(e.target.value) || 0)} + value={cfg[field] ?? ''} + placeholder="None" + onChange={e => updateQuota(cfg.modelId, field, parseOptionalLimit(e.target.value))} />
))} diff --git a/src/server/core/config.ts b/src/server/core/config.ts index 8ecc766..0872cb9 100644 --- a/src/server/core/config.ts +++ b/src/server/core/config.ts @@ -22,21 +22,10 @@ async function cacheKey(env: Pick, owner: string, repo: s const GLOBAL_CONFIG_KEY = 'config:global_model'; -const SERVER_DEFAULT_GLOBAL_CONFIG: RepoConfig['model'] = { - main: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], - size_overrides: [ - { - max_lines: 300, - model: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], - }, - { - max_lines: 100, - model: '@cf/moonshotai/kimi-k2.6', - fallbacks: ['@cf/zai-org/glm-4.7-flash'], - }, - ], +const EMPTY_GLOBAL_CONFIG: RepoConfig['model'] = { + main: null, + fallbacks: [], + size_overrides: [], }; function hasRepoModelOverride(existing: Awaited> | null) { @@ -51,7 +40,7 @@ export async function getGlobalConfig(env: Pick): Promise const cached = await env.APP_KV.get(GLOBAL_CONFIG_KEY, 'json'); if (cached) return normalizeRepoModelConfig(cached as RepoConfig['model']); - return SERVER_DEFAULT_GLOBAL_CONFIG; + return EMPTY_GLOBAL_CONFIG; } export async function updateGlobalConfig(env: Pick, config: RepoConfig['model']) { diff --git a/src/server/core/review.ts b/src/server/core/review.ts index d55f275..26b4c58 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -74,7 +74,7 @@ function configuredModelSet(config: RepoConfig) { if (model) models.add(normalizeModelId(model)); }; - addModel(config.model?.main ?? 'gemma-4-31b-it'); + addModel(config.model?.main); for (const fallback of config.model?.fallbacks ?? []) { addModel(fallback); } @@ -561,7 +561,7 @@ async function reviewAndPersistFile( const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; if (isRetryableModelError(error)) { - const modelId = config.model?.main ?? 'gemma-4-31b-it'; + const modelId = config.model?.main ?? 'unconfigured'; const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, @@ -619,7 +619,7 @@ async function reviewAndPersistFile( throw error; } - const modelId = config.model?.main ?? 'gemma-4-31b-it'; + const modelId = config.model?.main ?? 'unconfigured'; await upsertFileReview(env, job.id, { filePath: file.path, fileStatus: 'failed', diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index f15979a..8a4da92 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -26,9 +26,9 @@ type ModelConfigRow = { provider_name: string; api_format: LlmApiFormat; model_name: string; - rpm: number; - tpm: number; - rpd: number; + rpm: number | null; + tpm: number | null; + rpd: number | null; updated_at: string; }; @@ -351,9 +351,9 @@ export async function upsertDiscoveredModelConfigs( model_id: string; provider_id: string; model_name: string; - rpm: number; - tpm: number; - rpd: number; + rpm: number | null; + tpm: number | null; + rpd: number | null; provider: LlmApiFormat; }> = []; @@ -373,9 +373,9 @@ export async function upsertDiscoveredModelConfigs( model_id: candidate, provider_id: input.providerId, model_name: modelName, - rpm: 60, - tpm: 1_000_000, - rpd: 1_000, + rpm: null, + tpm: null, + rpd: null, provider: input.apiFormat, }); } diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index fe62104..307d504 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -28,6 +28,7 @@ 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(); @@ -47,13 +48,13 @@ const providerUpdateSchema = providerCreateSchema.extend({ const modelConfigUpdateSchema = z.object({ providerId: providerIdSchema, modelName: z.string().trim().min(1), - rpm: positiveIntegerSchema, - tpm: positiveIntegerSchema, - rpd: positiveIntegerSchema, + rpm: optionalLimitSchema, + tpm: optionalLimitSchema, + rpd: optionalLimitSchema, }).strict(); const globalModelConfigSchema = z.object({ - main: modelIdSchema.nullable().default('gemma-4-31b-it'), + main: modelIdSchema.nullable().default(null), fallbacks: z.array(modelIdSchema).nullable().default([]), size_overrides: z .array( diff --git a/src/server/services/model.ts b/src/server/services/model.ts index 6cb36c9..144a9a2 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -135,19 +135,11 @@ export class ModelService { const { model: modelCfg } = params.config; const thresholdBase = params.totalLineCount; - // Use default if not configured - if (!modelCfg) { - return { - primary: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'] - }; - } - - let selectedModel = normalizeModel(modelCfg.main ?? 'gemma-4-31b-it'); - let fallbackModels = (modelCfg.fallbacks || []).map(normalizeModel); + let selectedModel = modelCfg?.main ? normalizeModel(modelCfg.main) : null; + let fallbackModels = (modelCfg?.fallbacks || []).map(normalizeModel); // Apply size overrides based on total PR lines - if (modelCfg.size_overrides && modelCfg.size_overrides.length > 0) { + if (modelCfg?.size_overrides && modelCfg.size_overrides.length > 0) { const sortedOverrides = [...modelCfg.size_overrides].sort((a, b) => a.max_lines - b.max_lines); const matched = sortedOverrides.find(o => thresholdBase <= o.max_lines); if (matched) { @@ -156,8 +148,12 @@ export class ModelService { } } - const chain = uniqueModels([selectedModel, ...fallbackModels]); - selectedModel = chain[0] ?? 'gemma-4-31b-it'; + const chain = uniqueModels([...(selectedModel ? [selectedModel] : []), ...fallbackModels]); + if (chain.length === 0) { + throw new Error('No review model strategy is configured. Choose a global model strategy in Settings, or configure this repository.'); + } + + selectedModel = chain[0]; fallbackModels = chain.slice(1); return { primary: selectedModel, fallbacks: fallbackModels }; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index e88bb4e..4cdc2fc 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -123,7 +123,7 @@ export const repoConfigSchema = z.object({ }), model: z .object({ - main: z.string().nullable().default('gemma-4-31b-it'), + main: z.string().nullable().default(null), fallbacks: z.array(z.string()).nullable().default([]), size_overrides: z .array( @@ -137,8 +137,8 @@ export const repoConfigSchema = z.object({ .optional(), }) .default({ - main: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], + main: null, + fallbacks: [], size_overrides: [], }), }); @@ -315,8 +315,12 @@ export function normalizeModelId(model: string) { export function normalizeRepoModelConfig(model: RepoConfig['model']): RepoConfig['model'] { return { ...model, - main: model.main === null ? null : normalizeModelId(model.main), - fallbacks: model.fallbacks === null ? null : model.fallbacks.map(normalizeModelId), + main: model.main ? normalizeModelId(model.main) : null, + fallbacks: model.fallbacks === null + ? null + : Array.isArray(model.fallbacks) + ? model.fallbacks.map(normalizeModelId) + : [], size_overrides: model.size_overrides === null || model.size_overrides === undefined ? model.size_overrides : model.size_overrides.map((tier) => ({ @@ -356,9 +360,9 @@ export const modelConfigSchema = z.object({ providerName: z.string(), apiFormat: z.enum(llmApiFormats), modelName: z.string(), - rpm: z.number().int(), - tpm: z.number().int(), - rpd: z.number().int(), + rpm: z.number().int().nullable(), + tpm: z.number().int().nullable(), + rpd: z.number().int().nullable(), updatedAt: dateStringSchema, }); diff --git a/test/api.spec.ts b/test/api.spec.ts index eda857e..27e3d15 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -428,7 +428,7 @@ describe('Dashboard API Suite', () => { 'content-type': 'application/json', }, body: JSON.stringify({ - name: 'Disabled No Key Provider', + name: `Disabled No Key Provider ${Date.now()}`, apiFormat: 'openai', baseUrl: 'https://api.example.com/v1', enabled: false, @@ -458,6 +458,7 @@ describe('Dashboard API Suite', () => { const env = createTestEnv(); const token = await getAuthCookie(env); await saveTestProviderApiKey(env); + const discoveredModelName = `test-discovered-${Date.now()}`; vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = String(input); if (url.includes('/ai/models/search')) { @@ -471,7 +472,7 @@ describe('Dashboard API Suite', () => { return Response.json({ models: [ { - name: 'models/gemini-2.5-flash', + name: `models/${discoveredModelName}`, supportedGenerationMethods: ['generateContent'], }, ], @@ -489,7 +490,8 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(200); const data = await response.json() as ModelConfigsResponse; - expect(data.configs.some(config => config.modelName === 'gemini-2.5-flash')).toBe(true); + const discoveredGoogleModel = data.configs.find(config => config.modelName === discoveredModelName); + expect(discoveredGoogleModel).toMatchObject({ rpm: null, rpd: null, tpm: null }); expect(data.configs.some(config => config.providerName === 'Cloudflare' && config.modelName === '@cf/openai/gpt-oss-120b')).toBe(true); expect(data.syncErrors).toEqual([]); }); diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index e134e2c..73a414f 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -51,6 +51,15 @@ describe('ModelService', () => { }); }); + it('fails clearly when no model strategy is configured', () => { + const service = new ModelService(createTestEnv()); + + expect(() => (service as any).selectModel({ + totalLineCount: 1, + config: defaultRepoConfig, + })).toThrow('No review model strategy is configured'); + }); + it('turns Cloudflare reasoning-only responses into inconclusive review JSON', async () => { const env = createTestEnv({ AI: { diff --git a/test/settings.spec.ts b/test/settings.spec.ts index fbbe084..cbea8a4 100644 --- a/test/settings.spec.ts +++ b/test/settings.spec.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest'; import { normalizeGlobalConfig } from '@client/pages/settings'; describe('settings model strategy', () => { + it('does not invent a global strategy when none has been saved', () => { + expect(normalizeGlobalConfig(null)).toEqual({ + main: null, + fallbacks: [], + size_overrides: [], + }); + }); + it('preserves an explicit empty global fallback list', () => { const config = normalizeGlobalConfig({ main: 'gemma-4-31b-it', From 5eb8c1a74fec4dcf6d3509a187d2751c1b3e3a0c Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 20:28:08 +0530 Subject: [PATCH 16/32] fix: db migration assistant on existing dbs --- db/migrations/001_initial.sql | 27 ++---- scripts/migrate.mjs | 154 +++++++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 30 deletions(-) diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index a3232de..2a8a98d 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -159,22 +159,9 @@ CREATE TABLE IF NOT EXISTS model_configs ( provider TEXT NOT NULL ); -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) -VALUES - ('gemma-4-31b-it', 15, 1000000, 1500, 'google'), - ('gemma-4-26b-a4b-it', 30, 1000000, 1500, 'google'), - ('@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare'), - ('@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare') -ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - updated_at = now(); - DELETE FROM model_configs WHERE model_id = '@cf/moonshotai/kimi-k2.5'; -CREATE OR REPLACE FUNCTION pg_temp.replace_deprecated_model(input jsonb, old_value text, new_value text) +CREATE OR REPLACE FUNCTION public.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -183,14 +170,14 @@ AS $$ WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END WHEN 'array' THEN COALESCE( ( - SELECT jsonb_agg(pg_temp.replace_deprecated_model(value, old_value, new_value) ORDER BY ord) + SELECT jsonb_agg(public.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord) ), '[]'::jsonb ) WHEN 'object' THEN COALESCE( ( - SELECT jsonb_object_agg(key, pg_temp.replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, public.codra_replace_deprecated_model(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -204,21 +191,23 @@ SET main_model = CASE WHEN main_model = '@cf/moonshotai/kimi-k2.5' THEN '@cf/moonshotai/kimi-k2.6' ELSE main_model END, fallback_models = CASE WHEN fallback_models IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(fallback_models, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE public.codra_replace_deprecated_model(fallback_models, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END, size_overrides = CASE WHEN size_overrides IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(size_overrides, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE public.codra_replace_deprecated_model(size_overrides, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END, parsed_json = CASE WHEN parsed_json IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(parsed_json, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE public.codra_replace_deprecated_model(parsed_json, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END WHERE main_model = '@cf/moonshotai/kimi-k2.5' OR fallback_models::text LIKE '%@cf/moonshotai/kimi-k2.5%' OR size_overrides::text LIKE '%@cf/moonshotai/kimi-k2.5%' OR parsed_json::text LIKE '%@cf/moonshotai/kimi-k2.5%'; +DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text); + CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS repositories ( diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 3cf65bd..98291cb 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -229,6 +229,48 @@ async function ensureModelCatalog() { return; } + await query(` + CREATE TABLE IF NOT EXISTS llm_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), + base_url TEXT, + encrypted_api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + await query(` + UPDATE llm_providers + SET name = 'Cloudflare', updated_at = now() + WHERE name = 'Cloudflare Workers AI' + `); + + await query(` + UPDATE llm_providers + SET name = 'Google', updated_at = now() + WHERE name = 'Google Gemini' + `); + + await query(` + INSERT INTO llm_providers (name, api_format, base_url, enabled) + VALUES + ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), + ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), + ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), + ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), + ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) + ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now() + `); + + 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'); @@ -248,16 +290,104 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name) - SELECT $1, 10, 131072, 300, 'cloudflare', id, $1 - FROM llm_providers - WHERE name = 'Cloudflare' - ON CONFLICT (model_id) DO NOTHING + UPDATE model_configs mc + SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id) + FROM llm_providers provider_record + WHERE mc.provider_id IS NULL + AND ( + (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') + OR (mc.provider = 'gemini' AND provider_record.name = 'Google') + OR (mc.provider = 'google' AND provider_record.name = 'Google') + OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') + OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + ) + `, + ); + + await query('UPDATE model_configs SET model_name = model_id WHERE model_name IS NULL'); + + 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() + 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, + updated_at = now() `, [kimiK26Model], ); + 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() + 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, + updated_at = now() + `, + ); + + 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() + FROM llm_providers p + WHERE p.name = 'Google' + ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now() + `, + ); + + 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() + FROM llm_providers p + WHERE p.name = 'Google' + ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now() + `, + ); + await query('DELETE FROM model_configs WHERE model_id = $1', [kimiK25Model]); + + await query('ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL'); + await query('ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL'); + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' + ) THEN + ALTER TABLE model_configs + ADD CONSTRAINT model_configs_provider_id_fkey + FOREIGN KEY (provider_id) REFERENCES llm_providers(id); + END IF; + END $$ + `); + await query('CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id)'); } async function normalizeRepoConfigs() { @@ -266,7 +396,7 @@ async function normalizeRepoConfigs() { } await query(` - CREATE OR REPLACE FUNCTION pg_temp.replace_deprecated_model(input jsonb, old_value text, new_value text) + CREATE OR REPLACE FUNCTION public.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -275,14 +405,14 @@ async function normalizeRepoConfigs() { WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END WHEN 'array' THEN COALESCE( ( - SELECT jsonb_agg(pg_temp.replace_deprecated_model(value, old_value, new_value) ORDER BY ord) + SELECT jsonb_agg(public.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord) ), '[]'::jsonb ) WHEN 'object' THEN COALESCE( ( - SELECT jsonb_object_agg(key, pg_temp.replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, public.codra_replace_deprecated_model(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -299,15 +429,15 @@ async function normalizeRepoConfigs() { main_model = CASE WHEN main_model = $1 THEN $2 ELSE main_model END, fallback_models = CASE WHEN fallback_models IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(fallback_models, $1, $2) + ELSE public.codra_replace_deprecated_model(fallback_models, $1, $2) END, size_overrides = CASE WHEN size_overrides IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(size_overrides, $1, $2) + ELSE public.codra_replace_deprecated_model(size_overrides, $1, $2) END, parsed_json = CASE WHEN parsed_json IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(parsed_json, $1, $2) + ELSE public.codra_replace_deprecated_model(parsed_json, $1, $2) END WHERE main_model = $1 OR fallback_models::text LIKE '%' || $1 || '%' @@ -316,6 +446,8 @@ async function normalizeRepoConfigs() { `, [kimiK25Model, kimiK26Model], ); + + await query('DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text)'); } async function main() { From 04372bc3a2cfaf3b2a6fdb190448c2a158ea2579 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 21:13:44 +0530 Subject: [PATCH 17/32] add: consolidate LLM provider management and make rate limits optional --- src/client/lib/api.ts | 39 +++++++++++++++++++++++---------- src/client/pages/repos.tsx | 30 +++++++++++++++---------- src/server/core/review.ts | 23 ++++++++++++++++--- src/server/db/model-configs.ts | 5 +++-- src/server/models/cloudflare.ts | 8 ++++++- 5 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 475e817..7148d52 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -11,13 +11,30 @@ import type { SyncReposResponse, UpdatesEmailResponse, } from '@shared/api'; +import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); function pathSegment(value: string) { - return encodeURIComponent(value); + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('Path segment cannot be empty.'); + } + return encodeURIComponent(trimmed); } +type QueryValue = string | number | boolean | null | undefined; +type ModelConfigPayload = Pick; +type ProviderPayload = { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + apiKey?: string; + clearApiKey?: boolean; + enabled: boolean; +}; +type RepoConfigPatch = Partial & { enabled: boolean }>; + async function request(input: string, init?: RequestInit) { const method = init?.method?.toUpperCase() ?? 'GET'; const headers = new Headers(init?.headers); @@ -119,7 +136,7 @@ export const api = { body: JSON.stringify({ email }), }); }, - getJobs(params: Record = {}) { + getJobs(params: Record = {}) { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') { @@ -171,7 +188,7 @@ export const api = { body: JSON.stringify({ lease_ids: leaseIds }), }); }, - updateRepoConfig(owner: string, repo: string, config: any) { + updateRepoConfig(owner: string, repo: string, config: RepoConfigPatch) { return request<{ ok: boolean }>(`/api/repos/${owner}/${repo}/config`, { method: 'PATCH', body: JSON.stringify(config), @@ -185,8 +202,8 @@ export const api = { method: 'POST', }); }, - updateModelConfig(id: string, config: any) { - return request<{ ok: boolean; config: import('@shared/schema').ModelConfig }>(`/api/models/${pathSegment(id)}`, { + updateModelConfig(id: string, config: ModelConfigPayload) { + return request<{ ok: boolean; config: ModelConfig }>(`/api/models/${pathSegment(id)}`, { method: 'POST', body: JSON.stringify(config), }); @@ -196,14 +213,14 @@ export const api = { method: 'DELETE', }); }, - createProvider(config: any) { - return request<{ provider: any }>('/api/models/providers', { + createProvider(config: ProviderPayload) { + return request<{ provider: LlmProvider }>('/api/models/providers', { method: 'POST', body: JSON.stringify(config), }); }, - updateProvider(id: string, config: any) { - return request<{ provider: any }>(`/api/models/providers/${pathSegment(id)}`, { + updateProvider(id: string, config: ProviderPayload) { + return request<{ provider: LlmProvider }>(`/api/models/providers/${pathSegment(id)}`, { method: 'PATCH', body: JSON.stringify(config), }); @@ -219,9 +236,9 @@ export const api = { }); }, getGlobalConfig() { - return request<{ config: any }>('/api/models/global'); + return request<{ config: RepoConfig['model'] }>('/api/models/global'); }, - updateGlobalConfig(config: any) { + updateGlobalConfig(config: RepoConfig['model']) { return request<{ ok: boolean }>('/api/models/global', { method: 'PATCH', body: JSON.stringify(config), diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 3c2f712..8b599ee 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -18,7 +18,7 @@ import { X, } from 'lucide-react'; import { cn } from '@client/lib/utils'; -import type { RepoConfigRecord } from '@shared/schema'; +import type { RepoConfig, RepoConfigRecord } from '@shared/schema'; import { describeModelRoute, ModelRouteEditor, @@ -33,6 +33,8 @@ const EMPTY_MODEL_ROUTE: ModelRouteConfig = { size_overrides: [], }; +type GlobalModelConfig = RepoConfig['model']; + function repoId(repo: Pick) { return `${repo.owner}/${repo.repo}`; } @@ -41,7 +43,7 @@ function hasStoredModelStrategy(repo: RepoConfigRecord) { return repo.mainModel !== null || repo.fallbackModels !== null || repo.sizeOverrides !== null; } -function normalizeRoute(config: any): ModelRouteConfig { +function normalizeRoute(config: GlobalModelConfig | ModelRouteConfig | null | undefined): ModelRouteConfig { return { main: typeof config?.main === 'string' && config.main.trim() ? config.main : null, fallbacks: Array.isArray(config?.fallbacks) ? config.fallbacks : EMPTY_MODEL_ROUTE.fallbacks, @@ -51,7 +53,7 @@ function normalizeRoute(config: any): ModelRouteConfig { }; } -function getGlobalRoute(globalConfig: any): ModelRouteConfig { +function getGlobalRoute(globalConfig: GlobalModelConfig | ModelRouteConfig | null): ModelRouteConfig { return normalizeRoute(globalConfig); } @@ -69,7 +71,7 @@ function routesEqual(a: ModelRouteConfig, b: ModelRouteConfig) { return JSON.stringify(a) === JSON.stringify(b); } -function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: any) { +function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: GlobalModelConfig | ModelRouteConfig | null) { const storedRoute = getStoredRepoRoute(repo); if (!storedRoute) return false; @@ -79,7 +81,7 @@ function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: any) ); } -function getRepoRoute(repo: RepoConfigRecord, globalConfig: any): ModelRouteConfig { +function getRepoRoute(repo: RepoConfigRecord, globalConfig: GlobalModelConfig | ModelRouteConfig | null): ModelRouteConfig { if (!hasMeaningfulCustomStrategy(repo, globalConfig)) { return getGlobalRoute(globalConfig); } @@ -94,7 +96,7 @@ function formatLastActivity(value: string | Date | null) { interface RepoRowProps { repo: RepoConfigRecord; - globalConfig: any; + globalConfig: GlobalModelConfig | ModelRouteConfig | null; modelOptions: ModelOption[]; togglePending: boolean; onToggleEnabled: (repo: RepoConfigRecord, enabled: boolean) => void; @@ -190,7 +192,7 @@ function RepoRow({ interface RepoModelModalProps { repo: RepoConfigRecord | null; - globalConfig: any; + globalConfig: GlobalModelConfig | ModelRouteConfig | null; modelOptions: ModelOption[]; providerOptions: ProviderOption[]; open: boolean; @@ -343,7 +345,7 @@ function RepoModelModal({ export function ReposPage() { const [repos, setRepos] = useState([]); - const [globalConfig, setGlobalConfig] = useState(null); + const [globalConfig, setGlobalConfig] = useState(EMPTY_MODEL_ROUTE); const [modelOptions, setModelOptions] = useState([]); const [providerOptions, setProviderOptions] = useState([]); const [error, setError] = useState(null); @@ -362,10 +364,14 @@ export function ReposPage() { api.getModelConfigs(), ]) .then(([reposRes, globalRes, modelsRes]) => { - setRepos(reposRes.repos); - setGlobalConfig(globalRes.config); - setProviderOptions(modelsRes.providers.map(provider => ({ value: provider.id, label: provider.name }))); - setModelOptions(modelsRes.configs.map(config => ({ + const nextRepos = Array.isArray(reposRes?.repos) ? reposRes.repos : []; + const providers = Array.isArray(modelsRes?.providers) ? modelsRes.providers : []; + const configs = Array.isArray(modelsRes?.configs) ? modelsRes.configs : []; + + setRepos(nextRepos); + setGlobalConfig(normalizeRoute(globalRes?.config)); + setProviderOptions(providers.map(provider => ({ value: provider.id, label: provider.name }))); + setModelOptions(configs.map(config => ({ value: config.modelId, label: `${config.providerName} / ${config.modelName}`, providerId: config.providerId, diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 26b4c58..1be0274 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -3,6 +3,7 @@ import { isSupportedGitHubWebhookEvent, type GitHubWebhookEventName, type GitHub import { defaultRepoConfig, normalizeModelId, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema'; import type { AppBindings } from '@server/env'; import { getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; +import { getResolvedModelConfig } from '@server/db/model-configs'; import { claimJobLease, completeJob, completePreparationStep, failJob, findExistingJobForHead, getJobForProcessing, heartbeatJobLease, insertJob, mapJob, markJobCheckRunCompleted, markJobContinuationQueued, releaseJobLease, supersedeOlderJobs, updateJobCheckRun, updateJobStep } from '@server/db/jobs'; import { filterReviewableFiles, parseUnifiedDiff } from './diff'; @@ -92,6 +93,20 @@ function canInheritParentFileReview(config: RepoConfig, review: { model_used: st return configuredModelSet(config).has(normalizeModelId(review.model_used)); } +async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { + if (!modelId || modelId === 'unconfigured') return null; + + try { + const resolved = await getResolvedModelConfig(env, normalizeModelId(modelId)); + return resolved?.providerName ?? null; + } catch (error) { + logger.warn(`Failed to resolve provider for model ${modelId}`, { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + function shouldTriggerFromPullRequest(action: PullRequestWebhookPayload['action'], config: RepoConfig['review']) { return (config.on as string[]).includes(action); } @@ -562,10 +577,11 @@ async function reviewAndPersistFile( if (isRetryableModelError(error)) { const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveModelProviderName(env, modelId); const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, - modelProvider: 'configured', + modelProvider, diffLineCount: file.lineCount, diffInput: '', durationMs: Date.now() - startedAt, @@ -578,7 +594,7 @@ async function reviewAndPersistFile( filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: 'configured', + modelProvider, diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, @@ -620,11 +636,12 @@ async function reviewAndPersistFile( } const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveModelProviderName(env, modelId); await upsertFileReview(env, job.id, { filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: 'configured', + modelProvider, diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index 8a4da92..453f51e 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -332,6 +332,7 @@ export async function upsertDiscoveredModelConfigs( const uniqueModelNames = Array.from(new Set(input.modelNames.map(name => name.trim()).filter(Boolean))); if (uniqueModelNames.length === 0) return []; + const providerSlug = slugify(input.providerName); const [existingForProvider, existingModelIds] = await Promise.all([ queryRows<{ model_id: string; model_name: string }>( env, @@ -340,13 +341,13 @@ export async function upsertDiscoveredModelConfigs( ), queryRows<{ model_id: string }>( env, - `SELECT model_id FROM model_configs`, + `SELECT model_id FROM model_configs WHERE model_id LIKE $1`, + [`${providerSlug}:%`], ), ]); const existingModelNames = new Set(existingForProvider.map(row => row.model_name)); const usedModelIds = new Set(existingModelIds.map(row => row.model_id)); - const providerSlug = slugify(input.providerName); const rowsToInsert: Array<{ model_id: string; provider_id: string; diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 1e58899..8436c75 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -83,6 +83,12 @@ function getNumber(value: unknown, key: string) { return typeof child === 'number' ? child : null; } +function isLocalWorkersAiBindingError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return normalized.includes('binding ai') && normalized.includes('run remotely'); +} + function synthesizeInconclusiveReview(model: string, reason: string): string { logger.warn(`Cloudflare model ${model} returned no parseable review content; synthesizing inconclusive review JSON`, { reason, @@ -216,7 +222,7 @@ export async function reviewWithCloudflare( lastError = error; const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg.includes('Binding AI needs to be run remotely')) { + if (isLocalWorkersAiBindingError(error)) { const message = 'Cloudflare Workers AI is not available in local Wrangler. Run with remote bindings or deploy the Worker to test Cloudflare models.'; logger.warn(message, { model }); throw new ProviderRequestError(providerName, 400, message); From 426bda0e3c494fe4b99dfed0fe6386469f2453e0 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 22:16:07 +0530 Subject: [PATCH 18/32] fix: cleanup migrations, improve API robustness, and enhance settings UX --- db/migrations/001_initial.sql | 37 +++------ scripts/migrate.mjs | 22 ++++-- src/client/lib/api.ts | 8 +- src/client/pages/repos.tsx | 22 +++++- src/client/pages/settings.tsx | 136 ++++++++++++++++++++++++---------- src/server/core/github.ts | 43 ++++++----- src/server/core/review.ts | 17 +++-- 7 files changed, 181 insertions(+), 104 deletions(-) diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index 2a8a98d..ce8d2e1 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -31,7 +31,6 @@ CREATE TABLE IF NOT EXISTS repositories ( repo TEXT NOT NULL, UNIQUE(owner, repo) ); -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); CREATE TABLE IF NOT EXISTS jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -208,33 +207,6 @@ WHERE main_model = '@cf/moonshotai/kimi-k2.5' DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text); -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE IF NOT EXISTS repositories ( - installation_id BIGINT NOT NULL, - id SERIAL PRIMARY KEY, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - UNIQUE(owner, repo) -); - -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); - -CREATE TABLE IF NOT EXISTS review_comments ( - file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, - id BIGSERIAL PRIMARY KEY, - line INTEGER, - position INTEGER, - path TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'quality', - title TEXT NOT NULL, - body TEXT COMPRESSION lz4 NOT NULL, - code_suggestion TEXT COMPRESSION lz4 -); - -CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); - DO $$ DECLARE has_old_job_repo_columns BOOLEAN; @@ -601,6 +573,15 @@ WHERE mc.provider_id IS NULL OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') ); +UPDATE model_configs mc +SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id), + provider = 'cloudflare' +FROM llm_providers provider_record +WHERE mc.provider_id IS NULL + AND provider_record.name = 'Cloudflare'; + UPDATE model_configs SET model_name = model_id WHERE model_name IS NULL; diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 98291cb..29823d7 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -280,14 +280,6 @@ async function ensureModelCatalog() { WHERE rpm = 1 AND tpm = 1 AND rpd = 1 `); - await query( - ` - INSERT INTO llm_providers (name, api_format, base_url, enabled) - VALUES ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE) - ON CONFLICT (name) DO NOTHING - `, - ); - await query( ` UPDATE model_configs mc @@ -306,6 +298,19 @@ async function ensureModelCatalog() { `, ); + await query( + ` + UPDATE model_configs mc + SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id), + provider = 'cloudflare' + FROM llm_providers provider_record + WHERE mc.provider_id IS NULL + AND provider_record.name = 'Cloudflare' + `, + ); + await query('UPDATE model_configs SET model_name = model_id WHERE model_name IS NULL'); await query( @@ -466,6 +471,7 @@ async function main() { } } + await query('DROP INDEX IF EXISTS repositories_owner_idx'); await ensureModelCatalog(); await normalizeRepoConfigs(); diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 7148d52..929f784 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -24,8 +24,8 @@ function pathSegment(value: string) { } type QueryValue = string | number | boolean | null | undefined; -type ModelConfigPayload = Pick; -type ProviderPayload = { +export type ModelConfigPayload = Pick; +export type ProviderPayload = { name: string; apiFormat: LlmApiFormat; baseUrl: string | null; @@ -162,7 +162,7 @@ export const api = { return request('/api/repos'); }, getRepo(owner: string, repo: string) { - return request(`/api/repos/${owner}/${repo}/config`); + return request(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`); }, getStats(days?: number) { const query = days ? `?days=${days}` : ''; @@ -189,7 +189,7 @@ export const api = { }); }, updateRepoConfig(owner: string, repo: string, config: RepoConfigPatch) { - return request<{ ok: boolean }>(`/api/repos/${owner}/${repo}/config`, { + return request<{ ok: boolean }>(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`, { method: 'PATCH', body: JSON.stringify(config), }); diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 8b599ee..8332449 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -67,8 +67,28 @@ function getStoredRepoRoute(repo: RepoConfigRecord): ModelRouteConfig | null { }; } +function stringArraysEqual(a: string[] = [], b: string[] = []) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function tiersEqual(a: ModelRouteConfig['size_overrides'] = [], b: ModelRouteConfig['size_overrides'] = []) { + return a.length === b.length && a.every((tier, index) => { + const other = b[index]; + return Boolean( + other && + tier.max_lines === other.max_lines && + tier.model === other.model && + stringArraysEqual(tier.fallbacks ?? [], other.fallbacks ?? []), + ); + }); +} + function routesEqual(a: ModelRouteConfig, b: ModelRouteConfig) { - return JSON.stringify(a) === JSON.stringify(b); + return ( + a.main === b.main && + stringArraysEqual(a.fallbacks ?? [], b.fallbacks ?? []) && + tiersEqual(a.size_overrides ?? [], b.size_overrides ?? []) + ); } function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: GlobalModelConfig | ModelRouteConfig | null) { diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index a1089e4..3745060 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { api } from '@client/lib/api'; +import { api, type ProviderPayload } from '@client/lib/api'; import { PageHeader } from '@client/components/layout/page-header'; import { Button } from '@client/components/ui/button'; import { Alert } from '@client/components/ui/alert'; @@ -24,7 +24,7 @@ import { ChevronRight, X, } from 'lucide-react'; -import type { LlmApiFormat, LlmProvider, ModelConfig } from '@shared/schema'; +import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; import type { ModelConfigsResponse } from '@shared/api'; import { ModelRouteEditor, @@ -75,7 +75,9 @@ type NewModelDraft = { type SyncError = { providerId: string; providerName: string; error: string }; -export function normalizeGlobalConfig(config: any): ModelRouteConfig { +type GlobalConfigInput = RepoConfig['model'] | Partial | null | undefined; + +export function normalizeGlobalConfig(config: GlobalConfigInput): ModelRouteConfig { return { main: typeof config?.main === 'string' && config.main.trim() ? config.main : null, fallbacks: Array.isArray(config?.fallbacks) ? config.fallbacks : EMPTY_GLOBAL_CONFIG.fallbacks, @@ -83,8 +85,30 @@ export function normalizeGlobalConfig(config: any): ModelRouteConfig { }; } +function stringArraysEqual(a: string[] = [], b: string[] = []) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function tiersEqual(a: ModelRouteConfig['size_overrides'] = [], b: ModelRouteConfig['size_overrides'] = []) { + return a.length === b.length && a.every((tier, index) => { + const other = b[index]; + return Boolean( + other && + tier.max_lines === other.max_lines && + tier.model === other.model && + stringArraysEqual(tier.fallbacks ?? [], other.fallbacks ?? []), + ); + }); +} + function routeEqual(a: ModelRouteConfig | null, b: ModelRouteConfig | null) { - return JSON.stringify(a) === JSON.stringify(b); + if (a === b) return true; + if (!a || !b) return false; + return ( + a.main === b.main && + stringArraysEqual(a.fallbacks ?? [], b.fallbacks ?? []) && + tiersEqual(a.size_overrides ?? [], b.size_overrides ?? []) + ); } function configEqual(a?: ModelConfig, b?: ModelConfig) { @@ -127,6 +151,10 @@ function formatLabel(format: LlmApiFormat) { return API_FORMAT_OPTIONS.find(option => option.value === format)?.label ?? format; } +function domId(prefix: string, value: string) { + return `${prefix}-${value.replace(/[^a-zA-Z0-9_-]+/g, '-')}`; +} + function isCustomProvider(provider: Pick) { return provider.apiFormat !== 'cloudflare-workers-ai' && !FIXED_PROVIDER_NAMES.has(provider.name); } @@ -189,11 +217,11 @@ function SectionCard({ } /* ─── Field label ─────────────────────────────────────────────────────────── */ -function FieldLabel({ children }: { children: React.ReactNode }) { +function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) { return ( - + + ); } @@ -424,7 +452,7 @@ export function SettingsPage() { setError(null); const tid = toast.loading('Saving provider...'); try { - const payload: any = { + const payload: ProviderPayload = { name: provider.name, apiFormat: provider.apiFormat, baseUrl: provider.baseUrl || null, @@ -533,11 +561,23 @@ export function SettingsPage() { setError(null); const tid = toast.loading(`Saving ${dirtyConfigs.length} model change${dirtyConfigs.length === 1 ? '' : 's'}...`); try { - const saved = await Promise.all(dirtyConfigs.map(cfg => api.updateModelConfig(cfg.modelId, modelPayload(cfg)))); + 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)); - toast.success('Models saved', { id: tid }); + + 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); @@ -690,26 +730,25 @@ export function SettingsPage() {

New provider

+ { - const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0]; - setNewProvider(current => ({ - ...current, - preset: preset.value, - name: preset.name, - apiFormat: preset.apiFormat, - baseUrl: preset.baseUrl, - })); - }} - options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))} - /> -
-
- Name + Name setNewProvider(current => ({ ...current, name: e.target.value }))} @@ -719,16 +758,18 @@ export function SettingsPage() { )}
- Base URL + Base URL setNewProvider(current => ({ ...current, baseUrl: e.target.value }))} />
- API Key + API Key
- Name + Name updateProviderDraft(provider.id, { name: e.target.value })} /> @@ -871,8 +916,9 @@ export function SettingsPage() { options={API_FORMAT_OPTIONS.filter(option => option.value !== 'cloudflare-workers-ai')} />
- Base URL + Base URL updateProviderDraft(provider.id, { baseUrl: e.target.value || null })} @@ -886,8 +932,9 @@ export function SettingsPage() {

) : (
- API Key + API Key New model

- Codra model ID + Codra model ID setNewModel(current => ({ ...current, modelId: e.target.value }))} />
- Provider model name + Provider model name setNewModel(current => ({ ...current, modelName: e.target.value }))} @@ -1017,8 +1066,9 @@ export function SettingsPage() {
{(['rpm', 'rpd', 'tpm'] as const).map(field => (
- {field.toUpperCase()} + {field.toUpperCase()} item.modelId === cfg.modelId); const dirty = !configEqual(cfg, saved); const expanded = expandedModelId === cfg.modelId; + const providerModelNameId = domId('model-provider-name', cfg.modelId); return (
- Provider model name + Provider model name updateModel(cfg.modelId, { modelName: e.target.value })} />
- {(['rpm', 'rpd', 'tpm'] as const).map(field => ( + {(['rpm', 'rpd', 'tpm'] as const).map(field => { + const limitId = domId(`model-${field}`, cfg.modelId); + return (
- {field.toUpperCase()} + {field.toUpperCase()} updateQuota(cfg.modelId, field, parseOptionalLimit(e.target.value))} />
- ))} + ); + })}
diff --git a/src/server/core/github.ts b/src/server/core/github.ts index 1e2d8df..c651479 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -108,6 +108,10 @@ function encodeGitHubContentPath(path: string) { return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); } +function repoApiPath(owner: string, repo: string) { + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; +} + function pemToArrayBuffer(pem: string) { const base64 = pem .replace(/-----BEGIN (RSA )?PRIVATE KEY-----/g, '') @@ -374,7 +378,7 @@ export class GitHubClient { async getPullRequest(owner: string, repo: string, pullNumber: number) { return withRetry(`getPullRequest ${owner}/${repo}#${pullNumber}`, async () => { - const response = await this.requestAndCheck(`/repos/${owner}/${repo}/pulls/${pullNumber}`); + const response = await this.requestAndCheck(`${repoApiPath(owner, repo)}/pulls/${pullNumber}`); return (await response.json()) as PullRequestRecord; }); } @@ -382,7 +386,7 @@ export class GitHubClient { async getPullRequestDiff(owner: string, repo: string, pullNumber: number) { return withRetry(`getPullRequestDiff ${owner}/${repo}#${pullNumber}`, async () => { const response = await this.requestAndCheck( - `/repos/${owner}/${repo}/pulls/${pullNumber}`, + `${repoApiPath(owner, repo)}/pulls/${pullNumber}`, {}, 'application/vnd.github.v3.diff', ); @@ -392,7 +396,7 @@ export class GitHubClient { async getRepoFileOrNull(owner: string, repo: string, path: string) { return withRetry(`getRepoFileOrNull ${owner}/${repo}/${path}`, async () => { - const response = await this.request(`/repos/${owner}/${repo}/contents/${encodeGitHubContentPath(path)}`); + const response = await this.request(`${repoApiPath(owner, repo)}/contents/${encodeGitHubContentPath(path)}`); if (response.status === 404) { return null; } @@ -421,7 +425,7 @@ export class GitHubClient { input: { headSha: string; title: string; summary: string; detailsUrl?: string }, ) { return withRetry(`createCheckRun ${owner}/${repo}`, async () => { - const response = await this.requestAndCheck(`/repos/${owner}/${repo}/check-runs`, { + const response = await this.requestAndCheck(`${repoApiPath(owner, repo)}/check-runs`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -454,7 +458,7 @@ export class GitHubClient { }, ) { return withRetry(`updateCheckRun ${owner}/${repo} ${checkRunId}`, async () => { - await this.requestAndCheck(`/repos/${owner}/${repo}/check-runs/${checkRunId}`, { + await this.requestAndCheck(`${repoApiPath(owner, repo)}/check-runs/${checkRunId}`, { method: 'PATCH', headers: { 'content-type': 'application/json', @@ -497,7 +501,8 @@ export class GitHubClient { })), }; - let response = await this.request(`/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, { + const reviewPath = `${repoApiPath(owner, repo)}/pulls/${pullNumber}/reviews`; + let response = await this.request(reviewPath, { method: 'POST', headers: { 'content-type': 'application/json', @@ -511,7 +516,7 @@ export class GitHubClient { repo, pullNumber, }); - response = await this.request(`/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, { + response = await this.request(reviewPath, { method: 'POST', headers: { 'content-type': 'application/json', @@ -530,7 +535,7 @@ export class GitHubClient { throw new GitHubError( response.status, errText, - `/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, + reviewPath, `GitHub review creation failed with ${response.status}: ${errText}`, ); } @@ -541,7 +546,7 @@ export class GitHubClient { async ensureLabel(owner: string, repo: string, name: string, color: string) { return withRetry(`ensureLabel ${owner}/${repo} ${name}`, async () => { - const listResponse = await this.request(`/repos/${owner}/${repo}/labels/${encodeURIComponent(name)}`); + const listResponse = await this.request(`${repoApiPath(owner, repo)}/labels/${encodeURIComponent(name)}`); if (listResponse.ok) { return; } @@ -555,7 +560,7 @@ export class GitHubClient { ); } - const createResponse = await this.request(`/repos/${owner}/${repo}/labels`, { + const createResponse = await this.request(`${repoApiPath(owner, repo)}/labels`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -577,7 +582,7 @@ export class GitHubClient { async addIssueLabels(owner: string, repo: string, issueNumber: number, labels: string[]) { return withRetry(`addIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { - await this.requestAndCheck(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, { + await this.requestAndCheck(`${repoApiPath(owner, repo)}/issues/${issueNumber}/labels`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -589,10 +594,13 @@ export class GitHubClient { async listIssueLabels(owner: string, repo: string, issueNumber: number) { return withRetry(`listIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { - const response = await this.requestAndCheck(`/repos/${owner}/${repo}/issues/${issueNumber}/labels?per_page=100`); - const labels = (await response.json()) as GitHubIssueLabel[]; + const response = await this.requestAndCheck(`${repoApiPath(owner, repo)}/issues/${issueNumber}/labels?per_page=100`); + const labels = await response.json(); + if (!Array.isArray(labels)) { + throw new Error('Expected an array of labels from GitHub API.'); + } return labels - .map(label => label.name) + .map((label: GitHubIssueLabel) => label.name) .filter((name): name is string => typeof name === 'string' && name.length > 0); }); } @@ -601,18 +609,19 @@ export class GitHubClient { const currentLabels = await this.listIssueLabels(owner, repo, issueNumber); const currentByLowerName = new Map(currentLabels.map(label => [label.toLowerCase(), label])); - for (const label of labels) { + const uniqueLabels = Array.from(new Set(labels.map(label => label.toLowerCase()))); + await Promise.all(uniqueLabels.map(async (label) => { const currentLabel = currentByLowerName.get(label.toLowerCase()); if (currentLabel) { await this.removeIssueLabel(owner, repo, issueNumber, currentLabel); } - } + })); } async removeIssueLabel(owner: string, repo: string, issueNumber: number, label: string) { return withRetry(`removeIssueLabel ${owner}/${repo}#${issueNumber} ${label}`, async () => { const response = await this.request( - `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`, + `${repoApiPath(owner, repo)}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`, { method: 'DELETE', }, diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 1be0274..15ae035 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -439,6 +439,12 @@ async function runReviewPhase( const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + const failureModelId = config.model?.main ?? 'unconfigured'; + let failureModelProviderPromise: Promise | null = null; + const resolveFailureModelProvider = () => { + 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 totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0); @@ -462,13 +468,13 @@ async function runReviewPhase( const inherited = parentReviews.get(file.path); const reviewTask = async () => { if (!inherited) { - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); return; } if (!canInheritParentFileReview(config, inherited)) { logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); } else { await upsertFileReview(env, job.id, { filePath: file.path, @@ -540,6 +546,7 @@ async function reviewAndPersistFile( config: RepoConfig, totalLineCount: number, model: ModelService, + resolveFailureModelProvider: () => Promise, previousReview?: { transient_error_count: number }, ) { const startedAt = Date.now(); @@ -574,10 +581,10 @@ async function reviewAndPersistFile( }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; + const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveFailureModelProvider(); if (isRetryableModelError(error)) { - const modelId = config.model?.main ?? 'unconfigured'; - const modelProvider = await resolveModelProviderName(env, modelId); const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, @@ -635,8 +642,6 @@ async function reviewAndPersistFile( throw error; } - const modelId = config.model?.main ?? 'unconfigured'; - const modelProvider = await resolveModelProviderName(env, modelId); await upsertFileReview(env, job.id, { filePath: file.path, fileStatus: 'failed', From 1b580391331bd24e78f35f3f3c62b3072e3ac3e4 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 2 Jun 2026 03:44:54 +0530 Subject: [PATCH 19/32] add: improve overall settings ui --- .../features/models/model-chain.tsx | 149 +++++---- src/client/components/ui/select.tsx | 4 +- src/client/components/ui/switch.tsx | 10 +- src/client/pages/settings.tsx | 287 ++++++++++-------- 4 files changed, 233 insertions(+), 217 deletions(-) diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index 4775737..685f363 100644 --- a/src/client/components/features/models/model-chain.tsx +++ b/src/client/components/features/models/model-chain.tsx @@ -271,97 +271,92 @@ export function ModelRouteEditor({ : null; return ( -
-
-
-

Model routing

-

- Baseline route plus file-size tiers for smaller changes. -

-
- +
-
-
- Baseline route + {/* Baseline route card */} +
+
+ Baseline route {largestTier !== null && ( - Files over {largestTier} lines + · files over {largestTier} lines )}
- onChange({ ...value, main, fallbacks })} - /> -
+
+ onChange({ ...value, main, fallbacks })} + /> +
+
-
- {tiers.map((tier, index) => ( -
-
-
-

- Size tier -

-

- Files up to the selected line count use this route. -

+ {/* Size tier cards */} + {tiers.length > 0 && ( +
+ {tiers.map((tier, index) => ( +
+
+ + Size tier {tiers.length > 1 ? index + 1 : ''} + +
- -
- -
-
- -
- updateTier(index, { max_lines: Number(e.target.value) || 1 })} - className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none" - /> - lines +
+
+ +
+ updateTier(index, { max_lines: Number(e.target.value) || 1 })} + className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none" + /> + lines +
+ { + if (model) updateTier(index, { model, fallbacks }); + }} + />
- { - if (model) updateTier(index, { model, fallbacks }); - }} - />
-
- ))} -
+ ))} +
+ )}
); } diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx index d39ea15..5f03dab 100644 --- a/src/client/components/ui/select.tsx +++ b/src/client/components/ui/select.tsx @@ -49,7 +49,7 @@ export function Select({ - + + {addingProvider ? : } + {addingProvider ? 'Cancel' : 'Add'} +
- } - > +
+ {/* Add provider form */} {addingProvider && ( -
-

New provider

-
- { + const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0]; + setNewProvider(current => ({ + ...current, + preset: preset.value, + name: preset.name, + apiFormat: preset.apiFormat, + baseUrl: preset.baseUrl, + })); + }} + options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))} + /> +
- Name + Display name setNewProvider(current => ({ ...current, name: e.target.value }))} /> {selectedProviderNameExists && ( -

{newProvider.name.trim()} already exists

+

{newProvider.name.trim()} already exists

)}
@@ -767,43 +763,57 @@ export function SettingsPage() { />
- API Key + API key setNewProvider(current => ({ ...current, apiKey: e.target.value }))} />
-
- + + {saving === 'provider:new' ? : } + Create +
)} {/* Provider list */} {loading ? ( -
- - - +
+ {[148, 148, 148].map((h, i) => ( +
+
+ + +
+ +
+ ))}
- ) : providers.length === 0 ? ( -
- No providers configured yet. + ) : providers.length === 0 && !addingProvider ? ( +
+

No providers yet

+

Add one to start routing models.

) : ( -
+
{providers.map(provider => { const nativeCloudflare = provider.apiFormat === 'cloudflare-workers-ai'; const customProvider = isCustomProvider(provider); @@ -812,6 +822,7 @@ export function SettingsPage() { const modelCount = providerModelCounts.get(provider.id) ?? 0; const expanded = 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); @@ -820,36 +831,49 @@ export function SettingsPage() {
{/* Row */} -
- {/* Status dot */} - +
{/* Name + meta */}
-

{provider.name}

-

- {formatLabel(provider.apiFormat)} - {modelCount > 0 && · {modelCount} model{modelCount !== 1 ? 's' : ''}} +

{provider.name}

+

+ {provider.apiFormat} + {modelCount > 0 && ( + · {modelCount} model{modelCount !== 1 ? 's' : ''} + )}

- {/* Credential hint */} -

+ {/* Credential — hidden on smallest screens */} + {nativeCloudflare ? 'Worker binding' : provider.hasApiKey ? 'Key saved' : No key } -

+ {/* Controls */} -
+
+ {/* Save — only visible when dirty */} + {dirty && ( + + )} + { @@ -861,48 +885,43 @@ export function SettingsPage() { updateProviderDraft(provider.id, { enabled }); }} /> - - + {expanded ? : } + + {customProvider && ( - + )}
{/* Expanded edit panel */} {expanded && ( -
-
+
+
{customProvider && ( <>
- Name + Display name
updateProviderDraft(provider.id, { apiKey: e.target.value })} - className="max-w-md" + className="max-w-sm" />
)} @@ -952,23 +971,22 @@ export function SettingsPage() {
)} - {/* Catalog status footer */} + {/* Footer */} {!loading && ( -
-

+

+

{catalogRefreshing - ? 'Refreshing model lists…' + ? 'Syncing model lists…' : catalogRefreshedOnce - ? 'Model lists refreshed this session.' - : 'Loaded from the database.'} + ? 'Synced this session' + : 'Loaded from database'}

)} - + {/* ── Global model strategy ───────────────────────────────────────────── */} } title="Global model strategy" description="Account-wide baseline route and file-size tiers" action={ @@ -1003,7 +1021,6 @@ export function SettingsPage() { {/* ── Models & Usage Limits ────────────────────────────────────────────── */} } title="Models & usage limits" description={`${configs.length} models · provider mappings and rate limits`} action={ From 825e2f151693b6e76c9f75733176cbb3c5806240 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 2 Jun 2026 11:25:09 +0530 Subject: [PATCH 20/32] refactor: move job step completion to database layer --- .github/workflows/ci.yml | 4 ---- src/server/core/review.ts | 1 - src/server/db/jobs.ts | 27 +++++++++++++++++++++++++-- test/review-flow.spec.ts | 2 ++ wrangler.jsonc | 2 ++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 526bc02..ed0bd40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,16 +3,12 @@ name: Code Quality on: workflow_dispatch: push: - branches: - - main pull_request: types: - opened - synchronize - reopened - ready_for_review - branches: - - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 15ae035..2089cb8 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -760,7 +760,6 @@ async function runFinalizePhase( summaryModel: null, errorMessage: partialErrorMessage, }); - await updateJobStep(env, job.id, 'Completing', { status: 'done' }); logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); } diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index b97b83a..c06b884 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -549,6 +549,7 @@ export async function completeJob( errorMessage?: string | null; }, ) { + const now = new Date().toISOString(); await queryRows( env, ` @@ -567,7 +568,28 @@ export async function completeJob( review_id = $8, summary_model = $9, overall_confidence_score = $10, - error_msg = $11 + error_msg = $11, + steps = CASE + WHEN EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(steps, '[]'::jsonb)) s WHERE s->>'name' = 'Completing') + THEN ( + SELECT jsonb_agg( + CASE + WHEN s->>'name' = 'Completing' + THEN s || jsonb_build_object('status', 'done', 'finishedAt', $12::text, 'error', NULL) + ELSE s + END + ) FROM jsonb_array_elements(COALESCE(steps, '[]'::jsonb)) s + ) + ELSE COALESCE(steps, '[]'::jsonb) || jsonb_build_array( + jsonb_build_object( + 'name', 'Completing', + 'status', 'done', + 'startedAt', $12::text, + 'finishedAt', $12::text, + 'error', NULL + ) + ) + END WHERE id = $1 `, [ @@ -581,7 +603,8 @@ export async function completeJob( input.reviewId, input.summaryModel, input.overallConfidenceScore ?? null, - input.errorMessage ?? null + input.errorMessage ?? null, + now ], ); } diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index 4e70657..2e4676e 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -554,6 +554,8 @@ dbDescribe('Review Flow Lifecycle', () => { const finalJob = await getJobForProcessing(env, job.id); expect(finalJob?.status).toBe('done'); expect(finalJob?.error_msg).toContain('Partial review: 1 of 2 files'); + const steps = typeof finalJob?.steps === 'string' ? JSON.parse(finalJob.steps) : finalJob?.steps; + expect(steps?.find((step: { name: string }) => step.name === 'Completing')?.status).toBe('done'); expect(finalJob?.summary_markdown).toMatch(/^### Codra Review/); expect(finalJob?.summary_model).toBeNull(); expect(summarySpy).not.toHaveBeenCalled(); diff --git a/wrangler.jsonc b/wrangler.jsonc index 80ceecc..534f5ac 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -54,6 +54,8 @@ "max_batch_size": 1, "max_batch_timeout": 5, "max_concurrency": 1, + "visibility_timeout_ms": 900000, + "retry_delay": 60, "max_retries": 3 } ] From b49a81691860f14f3aa75e6d3bc3c5d8952ea7ee Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 2 Jun 2026 11:27:43 +0530 Subject: [PATCH 21/32] fix: database migrations, API methods, and label removal logic --- db/migrations/001_initial.sql | 16 +++++++++------- scripts/migrate.mjs | 15 ++++++++------- src/client/lib/api.ts | 2 +- src/client/pages/repos.tsx | 2 +- src/server/core/github.ts | 6 +++--- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index ce8d2e1..88045ea 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS model_configs ( DELETE FROM model_configs WHERE model_id = '@cf/moonshotai/kimi-k2.5'; -CREATE OR REPLACE FUNCTION public.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) +CREATE OR REPLACE FUNCTION pg_temp.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -169,14 +169,14 @@ AS $$ WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END WHEN 'array' THEN COALESCE( ( - SELECT jsonb_agg(public.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) + SELECT jsonb_agg(pg_temp.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord) ), '[]'::jsonb ) WHEN 'object' THEN COALESCE( ( - SELECT jsonb_object_agg(key, public.codra_replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, pg_temp.codra_replace_deprecated_model(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -190,22 +190,22 @@ SET main_model = CASE WHEN main_model = '@cf/moonshotai/kimi-k2.5' THEN '@cf/moonshotai/kimi-k2.6' ELSE main_model END, fallback_models = CASE WHEN fallback_models IS NULL THEN NULL - ELSE public.codra_replace_deprecated_model(fallback_models, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE pg_temp.codra_replace_deprecated_model(fallback_models, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END, size_overrides = CASE WHEN size_overrides IS NULL THEN NULL - ELSE public.codra_replace_deprecated_model(size_overrides, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE pg_temp.codra_replace_deprecated_model(size_overrides, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END, parsed_json = CASE WHEN parsed_json IS NULL THEN NULL - ELSE public.codra_replace_deprecated_model(parsed_json, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE pg_temp.codra_replace_deprecated_model(parsed_json, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END WHERE main_model = '@cf/moonshotai/kimi-k2.5' OR fallback_models::text LIKE '%@cf/moonshotai/kimi-k2.5%' OR size_overrides::text LIKE '%@cf/moonshotai/kimi-k2.5%' OR parsed_json::text LIKE '%@cf/moonshotai/kimi-k2.5%'; -DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text); +DROP FUNCTION IF EXISTS pg_temp.codra_replace_deprecated_model(jsonb, text, text); DO $$ DECLARE @@ -281,6 +281,7 @@ BEGIN ALTER TABLE jobs ADD COLUMN IF NOT EXISTS repository_id INTEGER; IF has_old_job_repo_columns THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS tmp_jobs_owner_repo_idx ON jobs(owner, repo)'; EXECUTE ' UPDATE jobs j SET repository_id = r.id @@ -289,6 +290,7 @@ BEGIN AND r.owner = j.owner AND r.repo = j.repo '; + EXECUTE 'DROP INDEX tmp_jobs_owner_repo_idx'; END IF; SELECT data_type diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 29823d7..80b22bf 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -294,6 +294,7 @@ async function ensureModelCatalog() { OR (mc.provider = 'google' AND provider_record.name = 'Google') OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + OR (mc.provider = 'openrouter' AND provider_record.name = 'OpenRouter') ) `, ); @@ -401,7 +402,7 @@ async function normalizeRepoConfigs() { } await query(` - CREATE OR REPLACE FUNCTION public.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) + CREATE OR REPLACE FUNCTION pg_temp.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -410,14 +411,14 @@ async function normalizeRepoConfigs() { WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END WHEN 'array' THEN COALESCE( ( - SELECT jsonb_agg(public.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) + SELECT jsonb_agg(pg_temp.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord) ), '[]'::jsonb ) WHEN 'object' THEN COALESCE( ( - SELECT jsonb_object_agg(key, public.codra_replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, pg_temp.codra_replace_deprecated_model(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -434,15 +435,15 @@ async function normalizeRepoConfigs() { main_model = CASE WHEN main_model = $1 THEN $2 ELSE main_model END, fallback_models = CASE WHEN fallback_models IS NULL THEN NULL - ELSE public.codra_replace_deprecated_model(fallback_models, $1, $2) + ELSE pg_temp.codra_replace_deprecated_model(fallback_models, $1, $2) END, size_overrides = CASE WHEN size_overrides IS NULL THEN NULL - ELSE public.codra_replace_deprecated_model(size_overrides, $1, $2) + ELSE pg_temp.codra_replace_deprecated_model(size_overrides, $1, $2) END, parsed_json = CASE WHEN parsed_json IS NULL THEN NULL - ELSE public.codra_replace_deprecated_model(parsed_json, $1, $2) + ELSE pg_temp.codra_replace_deprecated_model(parsed_json, $1, $2) END WHERE main_model = $1 OR fallback_models::text LIKE '%' || $1 || '%' @@ -452,7 +453,7 @@ async function normalizeRepoConfigs() { [kimiK25Model, kimiK26Model], ); - await query('DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text)'); + await query('DROP FUNCTION IF EXISTS pg_temp.codra_replace_deprecated_model(jsonb, text, text)'); } async function main() { diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 929f784..f48d43b 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -204,7 +204,7 @@ export const api = { }, updateModelConfig(id: string, config: ModelConfigPayload) { return request<{ ok: boolean; config: ModelConfig }>(`/api/models/${pathSegment(id)}`, { - method: 'POST', + method: 'PATCH', body: JSON.stringify(config), }); }, diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 8332449..c8817bf 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -75,7 +75,7 @@ function tiersEqual(a: ModelRouteConfig['size_overrides'] = [], b: ModelRouteCon return a.length === b.length && a.every((tier, index) => { const other = b[index]; return Boolean( - other && + tier && other && tier.max_lines === other.max_lines && tier.model === other.model && stringArraysEqual(tier.fallbacks ?? [], other.fallbacks ?? []), diff --git a/src/server/core/github.ts b/src/server/core/github.ts index c651479..8238f9c 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -610,12 +610,12 @@ export class GitHubClient { const currentByLowerName = new Map(currentLabels.map(label => [label.toLowerCase(), label])); const uniqueLabels = Array.from(new Set(labels.map(label => label.toLowerCase()))); - await Promise.all(uniqueLabels.map(async (label) => { - const currentLabel = currentByLowerName.get(label.toLowerCase()); + for (const label of uniqueLabels) { + const currentLabel = currentByLowerName.get(label); if (currentLabel) { await this.removeIssueLabel(owner, repo, issueNumber, currentLabel); } - })); + } } async removeIssueLabel(owner: string, repo: string, issueNumber: number, label: string) { From 720bc1826b1133c6ae0f2d0d22bc2c9aa9217a25 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 2 Jun 2026 22:14:26 +0530 Subject: [PATCH 22/32] add: automated cf setup configuration script --- package-lock.json | 328 ++++++++++++++++++++-- package.json | 4 + scripts/setup-cloudflare.js | 543 ++++++++++++++++++++++++++++++++++++ 3 files changed, 854 insertions(+), 21 deletions(-) create mode 100644 scripts/setup-cloudflare.js diff --git a/package-lock.json b/package-lock.json index a9e72f9..9909c30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,9 +47,12 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/browser": "^4.1.4", "@vitest/browser-playwright": "^4.1.4", + "chalk": "^5.6.2", "concurrently": "^9.2.1", "jsdom": "^29.0.2", + "ora": "^9.4.0", "playwright": "^1.59.1", + "prompts": "^2.4.2", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", "vite": "^8.0.8", @@ -3711,35 +3714,18 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3799,6 +3785,35 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3878,6 +3893,36 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4358,6 +4403,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4688,6 +4746,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4707,6 +4778,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -5057,6 +5141,23 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -5956,6 +6057,19 @@ ], "license": "MIT" }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -6065,6 +6179,91 @@ ], "license": "MIT" }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", + "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -6255,6 +6454,30 @@ "dev": true, "license": "MIT" }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6640,6 +6863,23 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -6847,6 +7087,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -6862,6 +7115,13 @@ "node": ">=18" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -6906,6 +7166,19 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7989,6 +8262,19 @@ "node": ">=12" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index 0f41312..8cb7355 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dev:client": "vite build --watch --mode development", "dev:worker": "wrangler dev --local", "start": "npm run dev", + "setup:cloudflare": "node scripts/setup-cloudflare.js", "migrate": "node scripts/migrate.mjs", "test": "node scripts/test.mjs", "test:watch": "vitest", @@ -40,9 +41,12 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/browser": "^4.1.4", "@vitest/browser-playwright": "^4.1.4", + "chalk": "^5.6.2", "concurrently": "^9.2.1", "jsdom": "^29.0.2", + "ora": "^9.4.0", "playwright": "^1.59.1", + "prompts": "^2.4.2", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", "vite": "^8.0.8", diff --git a/scripts/setup-cloudflare.js b/scripts/setup-cloudflare.js new file mode 100644 index 0000000..11dfe2a --- /dev/null +++ b/scripts/setup-cloudflare.js @@ -0,0 +1,543 @@ +import { exec, spawn } from 'node:child_process'; +import util from 'node:util'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import chalk from 'chalk'; +import ora from 'ora'; +import prompts from 'prompts'; + +const execAsync = util.promisify(exec); + +const WRANGLER_JSONC_PATH = path.join(process.cwd(), 'wrangler.jsonc'); +const DEV_VARS_PATH = path.join(process.cwd(), '.dev.vars'); + +async function runWranglerCmd(cmd, spinnerMessage) { + const spinner = ora(spinnerMessage).start(); + try { + const { stdout } = await execAsync(cmd); + spinner.succeed(); + return stdout; + } catch (error) { + spinner.fail(); + console.error(chalk.red(`\n❌ Error executing: ${cmd}`)); + const errorMsg = error.stderr || error.message; + console.error(chalk.red(errorMsg)); + + if (errorMsg.includes('[code: 10000]') || errorMsg.includes('Authentication error')) { + console.log(chalk.yellow('\n💡 Hint: Alternatively, run `npx wrangler login` to use your global Cloudflare session instead.')); + } + process.exit(1); + } +} + +function extractId(output) { + const match = output.match(/[a-f0-9]{32}/); + return match ? match[0] : null; +} + +async function handleKVNamespace(baseBinding, isPreview) { + const previewFlag = isPreview ? ' --preview' : ''; + let currentBinding = baseBinding; + + while (true) { + const spinner = ora(`Creating ${isPreview ? 'preview' : 'production'} KV namespace (${currentBinding})...`).start(); + try { + const { stdout } = await execAsync(`npx wrangler kv namespace create ${currentBinding}${previewFlag}`); + spinner.succeed(); + return extractId(stdout); + } catch (error) { + const errorMsg = error.stderr || error.message; + if (errorMsg.includes('already exists')) { + spinner.warn(`${isPreview ? 'Preview' : 'Production'} KV namespace for "${currentBinding}" already exists.`); + + const { action } = await prompts({ + type: 'select', + name: 'action', + message: `How would you like to handle this existing namespace?`, + choices: [ + { title: 'Auto-fetch existing ID', value: 'fetch' }, + { title: 'Manually enter ID', value: 'manual' }, + { title: 'Create new with different name', value: 'new' }, + { title: 'Skip', value: 'skip' } + ] + }, { onCancel: () => process.exit(1) }); + + if (action === 'fetch') { + const fetchSpinner = ora('Fetching existing KV namespaces...').start(); + try { + const { stdout: listOut } = await execAsync('npx wrangler kv namespace list'); + fetchSpinner.succeed(); + + let searchTitle = isPreview ? `${baseBinding}_preview` : baseBinding; + let parsed = null; + try { + const jsonStr = listOut.substring(listOut.indexOf('['), listOut.lastIndexOf(']') + 1); + parsed = JSON.parse(jsonStr); + } catch(e) {} + + if (parsed && Array.isArray(parsed)) { + const found = parsed.find(ns => ns.title.includes(searchTitle)); + if (found) { + console.log(chalk.green(` ✅ Found existing ID: ${found.id}`)); + return found.id; + } + } + + console.log(chalk.yellow(` ⚠️ Could not automatically find an ID matching ${searchTitle}.`)); + const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the KV Namespace ID manually:'}, { onCancel: () => process.exit(1) }); + if (manualId) return manualId; + return null; + } catch(e) { + fetchSpinner.fail('Failed to fetch KV namespaces.'); + const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the KV Namespace ID manually:'}, { onCancel: () => process.exit(1) }); + if (manualId) return manualId; + return null; + } + } else if (action === 'manual') { + const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the KV Namespace ID:'}, { onCancel: () => process.exit(1) }); + if (manualId) return manualId; + return null; + } else if (action === 'new') { + const { newName } = await prompts({ type: 'text', name: 'newName', message: 'Enter a new binding name (e.g. APP_KV_2):', initial: `${currentBinding}_2`}, { onCancel: () => process.exit(1) }); + if (newName) { + currentBinding = newName; + continue; + } + return null; + } else { + return null; + } + } else { + spinner.fail(); + console.error(chalk.red(`\n❌ Error executing KV creation.`)); + console.error(chalk.red(errorMsg)); + if (errorMsg.includes('[code: 10000]') || errorMsg.includes('Authentication error')) { + console.log(chalk.yellow('\n💡 Hint: Alternatively, run `npx wrangler login` to use your global Cloudflare session instead.')); + } + process.exit(1); + } + } + } +} + +async function handleHyperdrive(dbUrl) { + let currentBinding = 'codra-db'; + + while (true) { + const spinner = ora(`Creating Hyperdrive (${currentBinding})...`).start(); + try { + const { stdout } = await execAsync(`npx wrangler hyperdrive create ${currentBinding} --connection-string="${dbUrl}"`); + spinner.succeed(); + return extractId(stdout); + } catch (error) { + const errorMsg = error.stderr || error.message; + if (errorMsg.includes('already exists') || errorMsg.includes('code: 2017')) { + spinner.warn(`Hyperdrive config "${currentBinding}" already exists.`); + + const { action } = await prompts({ + type: 'select', + name: 'action', + message: `How would you like to handle this existing Hyperdrive?`, + choices: [ + { title: 'Auto-fetch existing ID', value: 'fetch' }, + { title: 'Manually enter ID', value: 'manual' }, + { title: 'Create new with different name', value: 'new' }, + { title: 'Skip', value: 'skip' } + ] + }, { onCancel: () => process.exit(1) }); + + if (action === 'fetch') { + const fetchSpinner = ora('Fetching existing Hyperdrive configs...').start(); + try { + const { stdout: listOut } = await execAsync('npx wrangler hyperdrive list'); + fetchSpinner.succeed(); + + let parsed = null; + try { + const jsonStr = listOut.substring(listOut.indexOf('['), listOut.lastIndexOf(']') + 1); + parsed = JSON.parse(jsonStr); + } catch(e) {} + + if (parsed && Array.isArray(parsed)) { + const found = parsed.find(hd => hd.name === currentBinding); + if (found) { + console.log(chalk.green(` ✅ Found existing ID: ${found.id}`)); + return found.id; + } + } else { + const lines = listOut.split('\n'); + for (const line of lines) { + if (line.includes(currentBinding)) { + const match = line.match(/[a-f0-9]{32}/); + if (match) { + console.log(chalk.green(` ✅ Found existing ID: ${match[0]}`)); + return match[0]; + } + } + } + } + + console.log(chalk.yellow(` ⚠️ Could not automatically find an ID matching ${currentBinding}.`)); + const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the Hyperdrive ID manually:'}, { onCancel: () => process.exit(1) }); + if (manualId) return manualId; + return null; + } catch(e) { + fetchSpinner.fail('Failed to fetch Hyperdrive configs.'); + const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the Hyperdrive ID manually:'}, { onCancel: () => process.exit(1) }); + if (manualId) return manualId; + return null; + } + } else if (action === 'manual') { + const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the Hyperdrive ID:'}, { onCancel: () => process.exit(1) }); + if (manualId) return manualId; + return null; + } else if (action === 'new') { + const { newName } = await prompts({ type: 'text', name: 'newName', message: 'Enter a new Hyperdrive name (e.g. codra-db-2):', initial: `${currentBinding}-2`}, { onCancel: () => process.exit(1) }); + if (newName) { + currentBinding = newName; + continue; + } + return null; + } else { + return null; + } + } else { + spinner.fail(); + console.error(chalk.red(`\n❌ Error executing Hyperdrive creation.`)); + console.error(chalk.red(errorMsg)); + process.exit(1); + } + } + } +} + +function getEnvVars() { + const env = {}; + if (fs.existsSync(DEV_VARS_PATH)) { + const content = fs.readFileSync(DEV_VARS_PATH, 'utf-8'); + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (line.trim() && !line.startsWith('#')) { + const [key, ...values] = line.split('='); + if (key && values.length > 0) { + env[key.trim()] = values.join('=').trim().replace(/^"|"$/g, ''); + } + } + } + } + return env; +} + +function setSecret(secretName, secretValue) { + return new Promise((resolve, reject) => { + const child = exec(`npx wrangler secret put ${secretName}`, (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(); + }); + + child.stdin.write(secretValue); + child.stdin.end(); + }); +} + +async function main() { + console.clear(); + console.log(chalk.blue.bold('\n☁️ Codra Cloudflare Setup\n')); + console.log(chalk.gray('This script will automatically configure your Cloudflare resources.\n')); + + const env = getEnvVars(); + + // 1. Prerequisites Check + const authSpinner = ora('Checking Cloudflare authentication...').start(); + let globallyAuthenticated = true; + try { + const { stdout, stderr } = await execAsync('npx wrangler whoami'); + const output = (stdout + (stderr || '')).toLowerCase(); + + // Wrangler sometimes exits with 0 even when not logged in + if (output.includes('not logged in') || output.includes('non-interactive environment') || output.includes('you are not authenticated')) { + throw new Error('Not logged in'); + } + authSpinner.succeed('Authenticated with Cloudflare.'); + } catch (error) { + globallyAuthenticated = false; + authSpinner.warn('Cloudflare is not authenticated in wrangler.'); + } + + if (!globallyAuthenticated) { + console.error(chalk.red('\n❌ You are not logged into Cloudflare.')); + console.log(chalk.yellow('Please run `npx wrangler login` in your terminal and try again.')); + process.exit(1); + } + + // 2. KV Namespace + console.log(chalk.cyan.bold('📦 KV Namespaces')); + const kvId = await handleKVNamespace('codra-review', false); + if (!kvId) console.log(chalk.yellow(' ⚠️ Could not extract KV ID.')); + + const kvPreviewId = await handleKVNamespace('codra-review', true); + if (!kvPreviewId) console.log(chalk.yellow(' ⚠️ Could not extract preview KV ID.')); + console.log(''); + + // 3. Queues + console.log(chalk.cyan.bold('📨 Queues')); + const dlqSpinner = ora('Creating DLQ queue (codra-review-dlq)...').start(); + try { + await execAsync('npx wrangler queues create codra-review-dlq'); + dlqSpinner.succeed(); + } catch (e) { + if (e.stderr && (e.stderr.includes('already taken') || e.stderr.includes('already exists'))) { + dlqSpinner.succeed('DLQ queue (codra-review-dlq) already exists.'); + } else { + dlqSpinner.fail(); + console.error(chalk.yellow(' ⚠️ ' + (e.stderr || e.message))); + } + } + + const jobsSpinner = ora('Creating jobs queue (codra-review-jobs)...').start(); + try { + await execAsync('npx wrangler queues create codra-review-jobs'); + jobsSpinner.succeed(); + } catch (e) { + if (e.stderr && (e.stderr.includes('already taken') || e.stderr.includes('already exists'))) { + jobsSpinner.succeed('Jobs queue (codra-review-jobs) already exists.'); + } else { + jobsSpinner.fail(); + console.error(chalk.yellow(' ⚠️ ' + (e.stderr || e.message))); + } + } + + let dlqQueueId = null; + const queuesOutputSpinner = ora('Fetching queue information...').start(); + try { + const { stdout } = await execAsync('npx wrangler queues list'); + queuesOutputSpinner.succeed(); + const lines = stdout.split('\n'); + for (const line of lines) { + if (line.includes('codra-review-dlq')) { + dlqQueueId = extractId(line); + } + } + } catch (e) { + queuesOutputSpinner.fail('Failed to fetch queues list.'); + console.error(chalk.yellow(' ⚠️ Could not automatically fetch DLQ queue ID. You may need to manually update CF_DLQ_ID.')); + } + console.log(''); + + // 4. Hyperdrive + console.log(chalk.cyan.bold('🗄️ Hyperdrive')); + console.log(chalk.gray(` (Using default from .dev.vars if available)`)); + const { dbUrl } = await prompts({ + type: 'text', + name: 'dbUrl', + message: 'Enter your Database Connection String for Hyperdrive:', + initial: env.DATABASE_URL || 'postgres://user:password@hostname:5432/codra' + }, { + onCancel: () => { + console.log(chalk.red('\n🛑 Setup aborted.')); + process.exit(1); + } + }); + + if (!dbUrl) { + console.log(chalk.red('❌ Database URL is required for Hyperdrive. Exiting.')); + process.exit(1); + } + + const hyperdriveId = await handleHyperdrive(dbUrl); + console.log(''); + + // 5. Domain Configuration + console.log(chalk.cyan.bold('🌐 Domain Configuration')); + const { domainChoice } = await prompts({ + type: 'select', + name: 'domainChoice', + message: 'Where would you like to deploy this application?', + choices: [ + { title: 'Use a workers.dev subdomain (Free & Easy)', value: 'workers_dev' }, + { title: 'Use a Custom Domain', value: 'custom_domain' } + ] + }, { onCancel: () => process.exit(1) }); + + let appUrl = ''; + let routesConfigStr = ''; + + if (domainChoice === 'workers_dev') { + routesConfigStr = `"workers_dev": true`; + const { workersDev } = await prompts({ + type: 'text', + name: 'workersDev', + message: 'What will be your workers.dev app URL? (e.g. https://codra.username.workers.dev):', + initial: 'https://codra..workers.dev' + }, { onCancel: () => process.exit(1) }); + appUrl = workersDev.replace(/\/$/, ''); + } else { + const { customDomain } = await prompts({ + type: 'text', + name: 'customDomain', + message: 'Enter your custom domain:', + initial: 'app.codra.devarshi.dev' + }, { onCancel: () => process.exit(1) }); + + appUrl = `https://${customDomain}`; + routesConfigStr = `"routes": [ + { + "pattern": "${customDomain}", + "custom_domain": true + } + ]`; + } + console.log(''); + + // 6. Application Variables + console.log(chalk.cyan.bold('📝 Application Variables')); + const { botUsername } = await prompts({ + type: 'text', + name: 'botUsername', + message: 'Enter your GitHub Bot Username:', + initial: 'codra-app' + }, { onCancel: () => process.exit(1) }); + + const { githubAppSlug } = await prompts({ + type: 'text', + name: 'githubAppSlug', + message: 'Enter your GitHub App Slug:', + initial: 'codra-app-personal' + }, { onCancel: () => process.exit(1) }); + + const { allowedUsers } = await prompts({ + type: 'text', + name: 'allowedUsers', + message: 'Enter comma-separated GitHub usernames allowed to access the dashboard:', + initial: 'devarshishimpi' + }, { onCancel: () => process.exit(1) }); + console.log(''); + + // 7. Config Update + console.log(chalk.cyan.bold('⚙️ Configuration')); + const configSpinner = ora('Updating wrangler.jsonc...').start(); + let wranglerConfig = fs.readFileSync(WRANGLER_JSONC_PATH, 'utf-8'); + let configChanged = false; + + const routeRegex = /"routes"\s*:\s*\[[\s\S]*?\]|"workers_dev"\s*:\s*(true|false)/; + wranglerConfig = wranglerConfig.replace(routeRegex, routesConfigStr); + + const appUrlRegex = /"APP_URL":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(appUrlRegex, `"APP_URL": "${appUrl}"`); + + const callbackUrlRegex = /"AUTH_CALLBACK_URL":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(callbackUrlRegex, `"AUTH_CALLBACK_URL": "${appUrl}/auth/github/callback"`); + + const botUsernameRegex = /"BOT_USERNAME":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(botUsernameRegex, `"BOT_USERNAME": "${botUsername}"`); + + const githubAppSlugRegex = /"GITHUB_APP_SLUG":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(githubAppSlugRegex, `"GITHUB_APP_SLUG": "${githubAppSlug}"`); + + const allowedUsersRegex = /"DASHBOARD_ALLOWED_USERS":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(allowedUsersRegex, `"DASHBOARD_ALLOWED_USERS": "${allowedUsers}"`); + + configChanged = true; + + if (kvId && kvPreviewId) { + wranglerConfig = wranglerConfig.replace( + /"binding":\s*"APP_KV",\s*"id":\s*"[^"]+",\s*"preview_id":\s*"[^"]+"/, + `"binding": "APP_KV",${os.EOL} "id": "${kvId}",${os.EOL} "preview_id": "${kvPreviewId}"` + ); + configChanged = true; + } + + if (hyperdriveId) { + wranglerConfig = wranglerConfig.replace( + /"binding":\s*"HYPERDRIVE",\s*"id":\s*"[^"]+"/, + `"binding": "HYPERDRIVE",${os.EOL} "id": "${hyperdriveId}"` + ); + configChanged = true; + } + + if (dlqQueueId) { + wranglerConfig = wranglerConfig.replace( + /"CF_DLQ_ID":\s*"[^"]+"/, + `"CF_DLQ_ID": "${dlqQueueId}"` + ); + configChanged = true; + } + + if (configChanged) { + fs.writeFileSync(WRANGLER_JSONC_PATH, wranglerConfig, 'utf-8'); + configSpinner.succeed('Updated wrangler.jsonc with new resource IDs.'); + } else { + configSpinner.warn('No IDs were successfully extracted. wrangler.jsonc was not modified.'); + } + console.log(''); + + // 8. Secrets + console.log(chalk.cyan.bold('🔐 Secrets')); + const requiredSecrets = [ + "APP_PRIVATE_KEY", + "GITHUB_APP_ID", + "GITHUB_APP_WEBHOOK_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "LLM_CONFIG_ENCRYPTION_KEY", + "CF_API_TOKEN", + "CF_ACCOUNT_ID" + ]; + + const { confirmSecrets } = await prompts({ + type: 'confirm', + name: 'confirmSecrets', + message: 'Would you like to interactively configure the required Cloudflare secrets now?', + initial: true + }, { + onCancel: () => { + console.log(chalk.red('\n🛑 Setup aborted.')); + process.exit(1); + } + }); + + if (confirmSecrets) { + console.log(''); + for (const secretName of requiredSecrets) { + let initialVal = env[secretName] || ''; + + const { secretValue } = await prompts({ + type: 'text', + name: 'secretValue', + message: `Value for ${secretName}:`, + initial: initialVal || undefined, + style: secretName === 'APP_PRIVATE_KEY' ? 'default' : 'password' + }, { + onCancel: () => { + console.log(chalk.red('\n🛑 Setup aborted.')); + process.exit(1); + } + }); + + if (secretValue) { + const spinner = ora(`Setting secret ${secretName}...`).start(); + try { + await setSecret(secretName, secretValue); + spinner.succeed(); + } catch (e) { + spinner.fail(); + console.error(chalk.red(` ❌ Failed to set secret ${secretName}: ${e.message}`)); + } + } else { + console.log(chalk.yellow(` ⏭️ Skipped ${secretName}`)); + } + } + } + + console.log(chalk.green.bold('\n=============================================')); + console.log(chalk.green.bold('🎉 Cloudflare Setup Successfully Completed!')); + console.log(chalk.green.bold('=============================================\n')); + console.log(chalk.white('You are all set. Run ') + chalk.cyan('npm run deploy') + chalk.white(' to deploy Codra to Cloudflare.\n')); +} + +main().catch(error => { + console.error(chalk.red('\n❌ An unexpected error occurred:')); + console.error(error); + process.exit(1); +}); From 6d361b58224fd8fec02258e3e29cf80aa6823d7f Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 7 Jun 2026 13:14:53 +0530 Subject: [PATCH 23/32] add: redesign UI with updated design system and version display --- README.md | 2 + src/client/app.css | 51 +++++++++++--------- src/client/components/layout/page-header.tsx | 8 ++- src/client/components/shared/jobs-table.tsx | 16 +++--- src/client/components/ui/button.tsx | 2 +- src/client/components/ui/dropdown-menu.tsx | 4 +- src/client/components/ui/input.tsx | 2 +- src/client/components/ui/select.tsx | 15 ++++-- src/client/pages/settings.tsx | 50 ++++++++++++++++++- 9 files changed, 109 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 411c3bb..19bb877 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts inline findings back to the PR, and gives you a dashboard to inspect jobs, repositories, model routing, review history, and failed queue runs. +> **Beta** -- Codra is under active development. Expect rough edges, missing features, and breaking changes between releases. Feedback and bug reports are welcome via [GitHub Issues](https://github.com/devarshishimpi/codra/issues). + ## Why Codra - **Own the whole review loop**: Run the GitHub App, Cloudflare Worker, queue, database, model credentials, and dashboard under your own control. diff --git a/src/client/app.css b/src/client/app.css index a09fe98..b1677d2 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -16,7 +16,7 @@ :root { /* Surfaces - Pure & Crisp */ /* Surfaces - High-Contrast */ - --background: #ffffff; + --background: #f4f4f5; --foreground: oklch(12% 0.02 115); --card: #ffffff; --card-foreground: oklch(12% 0.02 115); @@ -27,27 +27,27 @@ --primary: oklch(64% 0.24 115); --primary-foreground: oklch(100% 0 0); /* White text on the deeper green */ - /* Secondary / muted - Subtly cool */ - --secondary: oklch(96% 0.006 115); - --secondary-foreground:oklch(25% 0.020 115); - --muted: oklch(96% 0.006 115); - --muted-foreground: oklch(44% 0.015 115); + /* Secondary / muted - Zinc */ + --secondary: #f4f4f5; + --secondary-foreground:#27272a; + --muted: #f4f4f5; + --muted-foreground: #71717a; - /* Accent */ - --accent: oklch(94% 0.03 115); - --accent-foreground: oklch(18% 0.016 115); + /* Accent - slightly darker zinc for visible hover on white popovers */ + --accent: #e4e4e7; + --accent-foreground: #18181b; /* Destructive */ --destructive: oklch(55% 0.22 25); --destructive-foreground: oklch(100% 0 0); /* Border / input / ring */ - --border: oklch(88% 0.008 115); - --input: oklch(88% 0.008 115); + --border: #e4e4e7; + --input: #e4e4e7; --ring: oklch(72% 0.22 115); /* Radius */ - --radius: 0.5rem; + --radius: 0.75rem; --sidebar-width: 240px; --sidebar-collapsed-width: 72px; @@ -66,9 +66,9 @@ --info-border: oklch(88% 0.12 250); /* Premium Shadows */ - --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.05); - --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.06), 0 1px 4px oklch(0% 0 0 / 0.03); - --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.08), 0 4px 12px -2px oklch(0% 0 0 / 0.04); + --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02); + --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02); + --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03); /* Code Blocks (Zinc) */ --code-bg: #f4f4f5; @@ -173,10 +173,10 @@ --color-info-bg: var(--info-bg); --color-info-border: var(--info-border); - --radius-sm: 2px; - --radius-md: 4px; - --radius-lg: 8px; - --radius-xl: 16px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 18px; --radius-2xl: 32px; /* Fluid Typography Scale (Ratio: 1.25) */ @@ -495,9 +495,8 @@ Surface & Utilities ───────────────────────────────────────────────────── */ @utility surface { - @apply bg-card border border-border rounded-md; + @apply bg-card border border-border rounded-xl; box-shadow: var(--shadow-md); - &:hover { box-shadow: var(--shadow-md); } } .surface-static { @@ -657,11 +656,13 @@ } .app-shell-content { - --background: #ffffff; + --background: #f4f4f5; --card: #ffffff; - --muted: #ffffff; + --muted: #f4f4f5; --popover: #ffffff; - --secondary: #ffffff; + --secondary: #f4f4f5; + --border: #e4e4e7; + --input: #e4e4e7; } .dark .app-shell-content { @@ -670,6 +671,8 @@ --muted: #09090b; --popover: #09090b; --secondary: #09090b; + --border: oklch(22% 0.02 115); + --input: oklch(22% 0.02 115); } .dashboard-sidebar-divider { diff --git a/src/client/components/layout/page-header.tsx b/src/client/components/layout/page-header.tsx index 026c765..3590e19 100644 --- a/src/client/components/layout/page-header.tsx +++ b/src/client/components/layout/page-header.tsx @@ -7,6 +7,7 @@ interface PageHeaderProps extends React.HTMLAttributes { title: string; description?: React.ReactNode; actions?: React.ReactNode; + versionBadge?: string; } export function PageHeader({ @@ -28,10 +29,15 @@ export function PageHeader({ {category}

{title} + {props.versionBadge && ( + + v{props.versionBadge} + + )}

{description && (
diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx index 2bd99be..f135ccc 100644 --- a/src/client/components/shared/jobs-table.tsx +++ b/src/client/components/shared/jobs-table.tsx @@ -40,8 +40,8 @@ const thCls = 'px-4 py-3 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-muted-foreground select-none'; const COLUMN_CLASSES: Record = { - repo: 'w-[190px]', - pr: 'min-w-[280px]', + repo: 'w-[190px] max-w-[190px]', + pr: 'max-w-[480px]', status: 'w-[150px]', verdict: 'w-[120px]', files: 'hidden md:table-cell w-[76px]', @@ -262,7 +262,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) { return ( {cols.includes('repo') && ( -
+
-
-
+
+
#{job.prNumber} {job.prTitle ?? 'Untitled PR'} diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx index 9d79858..2ae9a8d 100644 --- a/src/client/components/ui/button.tsx +++ b/src/client/components/ui/button.tsx @@ -13,7 +13,7 @@ const buttonVariants = cva( destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', outline: - 'border border-input bg-card/60 shadow-sm hover:bg-secondary hover:text-secondary-foreground', + 'border border-zinc-200 bg-white shadow-sm hover:bg-zinc-50 hover:text-zinc-900 dark:border-input dark:bg-card/60 dark:hover:bg-secondary dark:hover:text-secondary-foreground', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', ghost: 'hover:bg-secondary hover:text-secondary-foreground', diff --git a/src/client/components/ui/dropdown-menu.tsx b/src/client/components/ui/dropdown-menu.tsx index df58e9a..32c61fc 100644 --- a/src/client/components/ui/dropdown-menu.tsx +++ b/src/client/components/ui/dropdown-menu.tsx @@ -16,10 +16,10 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const menuContentClass = - 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl shadow-black/10 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'; + 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white p-1 text-zinc-900 shadow-lg shadow-black/[0.06] 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-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground dark:hover:bg-primary/[0.12] dark:focus:bg-primary/[0.12] dark:data-[highlighted]:bg-primary/[0.12] data-[disabled]:pointer-events-none data-[disabled]:opacity-50'; + '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, diff --git a/src/client/components/ui/input.tsx b/src/client/components/ui/input.tsx index 345dcbc..1970d23 100644 --- a/src/client/components/ui/input.tsx +++ b/src/client/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef(({ className, type, @@ -77,7 +85,8 @@ export function Select({ onClick={() => 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]' + value === option.value && + 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary', )} > {option.label} diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index cde3db8..371b1ca 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import pkg from '../../../package.json'; import { toast } from 'sonner'; import { api, type ProviderPayload } from '@client/lib/api'; import { PageHeader } from '@client/components/layout/page-header'; @@ -21,6 +22,9 @@ import { ChevronDown, ChevronRight, X, + Tag, + ExternalLink, + GitCommit, } from 'lucide-react'; import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; import type { ModelConfigsResponse } from '@shared/api'; @@ -1142,7 +1146,7 @@ export function SettingsPage() { ))}
) : ( -
+
{filteredConfigs.map((cfg) => { const saved = savedConfigs.find(item => item.modelId === cfg.modelId); const dirty = !configEqual(cfg, saved); @@ -1280,6 +1284,50 @@ export function SettingsPage() {
)} + + {/* ── System Information ────────────────────────────────────────────── */} + +
+ + {/* Version row */} +
+ + + +
+

Version

+

Installed Codra release

+
+ + v{pkg.version} + +
+ + {/* Changelog / links row */} +
+ + + +
+

Releases

+

Browse all versions and release notes on GitHub

+
+ + View releases + + +
+ +
+
); } From b5361fd07f5e3deb971e1350962f69d2f5c96d1d Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 7 Jun 2026 21:21:08 +0530 Subject: [PATCH 24/32] refactor: lazy load pages + redesign auth pages - Version 0.9.2 - Implement code splitting with React.lazy for all pages - Add Suspense fallbacks for async route components - Redesign landing page (hero, sign-in, feature layout) - Redesign login page (cleaner UI, security note) - Minor app-shell spacing adjustment --- package.json | 2 +- src/client/components/layout/app-shell.tsx | 1 + src/client/main.tsx | 50 +++++---- src/client/pages/landing.tsx | 120 ++++++++++++--------- src/client/pages/login.tsx | 75 ++++++------- 5 files changed, 137 insertions(+), 111 deletions(-) diff --git a/package.json b/package.json index 8cb7355..0210a1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codra", - "version": "0.9.0", + "version": "0.9.2", "description": "Open-source code review engine", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", diff --git a/src/client/components/layout/app-shell.tsx b/src/client/components/layout/app-shell.tsx index 18f99a8..f467b26 100644 --- a/src/client/components/layout/app-shell.tsx +++ b/src/client/components/layout/app-shell.tsx @@ -146,6 +146,7 @@ export function AppShell() { className={cn( 'flex min-w-0 items-center gap-2.5 rounded-lg p-1 -m-1', 'transition-opacity duration-150 hover:opacity-75', + !sidebarCollapsed && 'lg:ml-1.5', sidebarCollapsed && 'lg:justify-center', )} aria-label="Codra dashboard" diff --git a/src/client/main.tsx b/src/client/main.tsx index 95b7dbf..7e40f75 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,18 +1,20 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import ReactDOM from 'react-dom/client'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { Toaster } from 'sonner'; import { AppShell } from './components/layout/app-shell'; -import { LandingPage } from './pages/landing'; -import { DashboardPage } from './pages/dashboard'; -import { LoginPage } from './pages/login'; -import { JobsPage } from './pages/jobs'; -import { JobDetailPage } from './pages/job-detail'; -import { JobLogsPage } from './pages/job-logs'; -import { ReposPage } from './pages/repos'; -import { StatsPage } from './pages/stats'; -import { SettingsPage } from './pages/settings'; -import { NotFoundPage } from './pages/not-found'; + +const LandingPage = React.lazy(() => import('./pages/landing').then(m => ({ default: m.LandingPage }))); +const DashboardPage = React.lazy(() => import('./pages/dashboard').then(m => ({ default: m.DashboardPage }))); +const LoginPage = React.lazy(() => import('./pages/login').then(m => ({ default: m.LoginPage }))); +const JobsPage = React.lazy(() => import('./pages/jobs').then(m => ({ default: m.JobsPage }))); +const JobDetailPage = React.lazy(() => import('./pages/job-detail').then(m => ({ default: m.JobDetailPage }))); +const JobLogsPage = React.lazy(() => import('./pages/job-logs').then(m => ({ default: m.JobLogsPage }))); +const ReposPage = React.lazy(() => import('./pages/repos').then(m => ({ default: m.ReposPage }))); +const StatsPage = React.lazy(() => import('./pages/stats').then(m => ({ default: m.StatsPage }))); +const SettingsPage = React.lazy(() => import('./pages/settings').then(m => ({ default: m.SettingsPage }))); +const NotFoundPage = React.lazy(() => import('./pages/not-found').then(m => ({ default: m.NotFoundPage }))); + import './app.css'; import { ThemeProvider } from './lib/theme'; @@ -49,30 +51,36 @@ function ToasterWrapper() { ); } +const withSuspense = (Component: React.ComponentType, isFullPage = false) => ( + }> + + +); + const router = createBrowserRouter([ { path: '/', - element: , + element: withSuspense(LandingPage, true), }, { path: '/login', - element: , + element: withSuspense(LoginPage, true), }, { element: , children: [ - { path: 'dashboard', element: }, - { path: 'jobs', element: }, - { path: 'jobs/:id', element: }, - { path: 'jobs/:id/logs', element: }, - { path: 'repos', element: }, - { path: 'stats', element: }, - { path: 'settings', element: }, + { path: 'dashboard', element: withSuspense(DashboardPage) }, + { path: 'jobs', element: withSuspense(JobsPage) }, + { path: 'jobs/:id', element: withSuspense(JobDetailPage) }, + { path: 'jobs/:id/logs', element: withSuspense(JobLogsPage) }, + { path: 'repos', element: withSuspense(ReposPage) }, + { path: 'stats', element: withSuspense(StatsPage) }, + { path: 'settings', element: withSuspense(SettingsPage) }, ], }, { path: '*', - element: , + element: withSuspense(NotFoundPage, true), }, ]); diff --git a/src/client/pages/landing.tsx b/src/client/pages/landing.tsx index 86d4233..cb5fb86 100644 --- a/src/client/pages/landing.tsx +++ b/src/client/pages/landing.tsx @@ -3,63 +3,91 @@ import { useTheme } from '@client/lib/theme'; import codraDark from '@/assets/codra-fullicon-dark.svg'; import codraLight from '@/assets/codra-fullicon-light.svg'; +const FEATURES = [ + { + title: 'Understands your codebase', + desc: 'Reviews diffs with full context from the surrounding code, not just the changed lines.', + }, + { + title: 'Flags real issues', + desc: 'Security vulnerabilities, logic errors, and pattern violations — surfaced before merge.', + }, + { + title: 'Configurable per repo', + desc: 'Set review depth, model chain, and strictness from the dashboard. No config files.', + }, +]; + export function LandingPage() { const { theme, toggleTheme } = useTheme(); return (
- {/* Header */} -
+ {/* ── Header ── */} +
Codra - +
+ + Sign in + + + +
- {/* Body */} -
+ {/* ── Body ── */} +
- {/* Left — Identity & CTA */} -
+ {/* Left — Hero */} +
-
-
-

- AI code review
- on every PR. +
+ {/* Badge */} + + AI-powered · GitHub App + + +
+

+ AI code review
on every PR.

-

- Codra reviews pull requests automatically — checking for bugs, security issues, - and code patterns specific to your repository. +

+ Codra reviews pull requests automatically — checking for bugs, + security issues, and code patterns specific to your repository.

-
{/* Footer links */} - - {/* Right — What it does */} -
-
- {[ - { - title: 'Understands your codebase', - desc: 'Reviews diffs with context from the surrounding code, not just the changed lines.', - }, - { - title: 'Flags real issues', - desc: 'Security vulnerabilities, logic errors, and pattern violations — surfaced before merge.', - }, - { - title: 'Configurable per repo', - desc: 'Set review depth, model chain, and strictness from the dashboard. No config files.', - }, - ].map((item) => ( -
-

{item.title}

-

{item.desc}

+ {/* Right — Features */} +
+

+ What it does +

+ +
+ {FEATURES.map((item, i) => ( +
+ + {i + 1} + +
+

{item.title}

+

{item.desc}

+
))}
diff --git a/src/client/pages/login.tsx b/src/client/pages/login.tsx index 0300777..e94937c 100644 --- a/src/client/pages/login.tsx +++ b/src/client/pages/login.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Button } from '@client/components/ui/button'; -import { Sun, Moon } from 'lucide-react'; +import { Sun, Moon, ShieldCheck } from 'lucide-react'; import { useTheme } from '@client/lib/theme'; import codraDark from '@/assets/codra-fullicon-dark.svg'; import codraLight from '@/assets/codra-fullicon-light.svg'; @@ -29,7 +29,7 @@ export function LoginPage() { const error = useMemo(() => getErrorMessage(searchParams.get('error')), [searchParams]); return ( -
+
-