diff --git a/.dev.vars.example b/.dev.vars.example index f6ad225..da963bf 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,52 +1,31 @@ -# ────────────────────────────────────────────────────────────────────────────── -# 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" +# --- Integration tests --- +TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra" -# --- Dashboard OAuth (GitHub) --- -# Use the same GitHub App's Client ID/Secret or a separate OAuth App +# --- 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" +GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID" 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" - -# --- AI Intelligence (Gemini) --- -# Generate at: https://aistudio.google.com/app/apikey -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" +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 --- +# 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_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..0cf4c8d --- /dev/null +++ b/.env.test.example @@ -0,0 +1,20 @@ +# 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" +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. +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..ed0bd40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: Code Quality on: + workflow_dispatch: push: - branches: - - main pull_request: - branches: - - main + types: + - opened + - synchronize + - reopened + - ready_for_review concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -16,6 +18,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..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. @@ -52,10 +53,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/README.md b/README.md index eb897de..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. @@ -47,7 +49,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 +67,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/001_initial.sql b/db/migrations/001_initial.sql index 4219560..7d63055 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -1,180 +1,166 @@ -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') -ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - updated_at = now(); +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 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 +); 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 pg_temp.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -183,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(pg_temp.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, pg_temp.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 @@ -204,331 +190,470 @@ 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 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 pg_temp.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 pg_temp.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 fallback_models @> '["@cf/moonshotai/kimi-k2.5"]'::jsonb + OR size_overrides @> '[{"model": "@cf/moonshotai/kimi-k2.5"}]'::jsonb + OR size_overrides @> '[{"fallbacks": ["@cf/moonshotai/kimi-k2.5"]}]'::jsonb 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 $$; +DROP FUNCTION IF EXISTS pg_temp.codra_replace_deprecated_model(jsonb, text, text); + +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 'CREATE INDEX IF NOT EXISTS tmp_jobs_owner_repo_idx ON jobs(owner, repo)'; + 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 + '; + EXECUTE 'DROP INDEX tmp_jobs_owner_repo_idx'; + 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 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; + +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/package-lock.json b/package-lock.json index c53d03b..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", @@ -58,9 +61,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 +419,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 +443,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 +460,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 +494,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 +1015,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 +1678,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 +2499,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 +2525,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 +2535,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 +2552,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 +2569,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 +2586,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 +2603,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 +2620,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 +2640,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 +2660,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 +2680,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 +2700,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 +2720,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 +2740,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 +2757,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 +2776,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 +2793,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 +2810,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 +2849,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 +2906,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 +2923,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 +2940,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 +2957,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 +2974,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 +2994,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 +3014,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 +3034,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 +3054,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 +3072,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 +3084,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 +3101,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 +3118,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 +3222,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 +3330,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 +3369,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 +3386,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 +3417,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 +3449,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 +3468,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 +3487,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.5" + "vitest": "4.1.7" }, "peerDependenciesMeta": { "playwright": { @@ -3493,16 +3496,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 +3514,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 +3541,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 +3554,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 +3568,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 +3584,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 +3594,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" }, @@ -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", @@ -4156,9 +4201,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": { @@ -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", @@ -4558,9 +4616,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" @@ -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,10 +4778,23 @@ "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.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": { @@ -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", @@ -5068,9 +5169,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 +5179,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" @@ -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", @@ -6137,13 +6336,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 +6355,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 +6378,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 +6398,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -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", @@ -6276,30 +6499,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 +6554,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 +6624,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 +6646,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" @@ -6640,15 +6863,32 @@ "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.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 +6897,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 +7004,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": { @@ -6794,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", @@ -6809,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", @@ -6853,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", @@ -6950,9 +7276,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 +7286,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 +7320,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 +7357,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 +7473,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 +7693,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 +7719,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 +7786,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 +7826,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 +7972,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 +7982,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 +7999,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260430.1" + "@cloudflare/workers-types": "^4.20260521.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -7681,9 +8008,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 +8025,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 +8042,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 +8059,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 +8076,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 +8093,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 +8124,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 +8137,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 +8185,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": { @@ -7935,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", @@ -7961,9 +8301,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/package.json b/package.json index d8db3c7..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", @@ -21,8 +21,9 @@ "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": "vitest run", + "test": "node scripts/test.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, @@ -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/fix-max-files.mjs b/scripts/fix-max-files.mjs new file mode 100644 index 0000000..2377f16 --- /dev/null +++ b/scripts/fix-max-files.mjs @@ -0,0 +1,45 @@ +import postgres from 'postgres'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function readDatabaseUrlFromEnvFiles() { + const envFiles = ['.dev.vars', '.env.local', '.env']; + for (const file of envFiles) { + try { + const content = await readFile(path.join(rootDir, file), 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith('DATABASE_URL=')) { + let val = trimmed.slice(13).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + return val.slice(1, -1); + } + return val; + } + } + } catch {} + } + return null; +} + +const databaseUrl = process.env.DATABASE_URL ?? await readDatabaseUrlFromEnvFiles(); +const sql = postgres(databaseUrl, { onnotice: () => {} }); + +async function run() { + try { + const result = await sql` + UPDATE repo_configs + SET parsed_json = jsonb_set(parsed_json, '{review,max_files}', '100'::jsonb) + WHERE parsed_json#>>'{review,max_files}' = '15' + `; + console.log(`Updated ${result.count} repository configurations from 15 to 100.`); + } catch (err) { + console.error(err); + } finally { + await sql.end(); + } +} +run(); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 9063499..bff796f 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -229,16 +229,172 @@ 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'); + await query(` + UPDATE model_configs + SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now() + WHERE rpm = 1 AND tpm = 1 AND rpd = 1 + `); + + await query( + ` + UPDATE model_configs mc + 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') + OR (mc.provider = 'openrouter' AND provider_record.name = 'OpenRouter') + ) + `, + ); + await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) - VALUES ($1, 10, 131072, 300, '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), + provider = 'cloudflare' + FROM llm_providers provider_record + WHERE mc.provider_id IS NULL + AND provider_record.name = 'Cloudflare' + AND mc.model_id LIKE '@cf/%' + `, + ); + + 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() { @@ -246,8 +402,12 @@ async function normalizeRepoConfigs() { return; } + console.log('Normalizing repo configs...'); + const functionName = 'codra_replace_deprecated_model'; + + console.log(`Creating function: pg_temp.${functionName}`); await query(` - CREATE OR REPLACE FUNCTION pg_temp.replace_deprecated_model(input jsonb, old_value text, new_value text) + CREATE FUNCTION pg_temp.${functionName}(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -256,14 +416,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(pg_temp.${functionName}(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, pg_temp.${functionName}(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -273,6 +433,7 @@ async function normalizeRepoConfigs() { $$ `); + console.log('Updating repo configs...'); await query( ` UPDATE repo_configs @@ -280,46 +441,66 @@ 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 pg_temp.${functionName}(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 pg_temp.${functionName}(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 pg_temp.${functionName}(parsed_json, $1, $2) END WHERE main_model = $1 - OR fallback_models::text LIKE '%' || $1 || '%' - OR size_overrides::text LIKE '%' || $1 || '%' + OR fallback_models @> jsonb_build_array($1::text) + OR size_overrides @> jsonb_build_array(jsonb_build_object('model', $1::text)) + OR size_overrides @> jsonb_build_array(jsonb_build_object('fallbacks', jsonb_build_array($1::text))) OR parsed_json::text LIKE '%' || $1 || '%' `, [kimiK25Model, kimiK26Model], ); + + console.log(`Dropping function: pg_temp.${functionName}`); + await query(`DROP FUNCTION IF EXISTS pg_temp.${functionName}(jsonb, text, text)`); + console.log('Repo configs normalized.'); } async function main() { - await query('SELECT pg_advisory_lock($1)', [migrationLockId]); try { - await ensureMigrationTable(); + console.log('Acquiring advisory lock...'); + await query('SELECT pg_advisory_lock($1)', [migrationLockId]); - const migrationFiles = (await readdir(migrationsDir)) - .filter((name) => /^\d+_.+\.sql$/.test(name)) - .sort(); + console.log('Starting database migrations...'); + await query('BEGIN'); + try { + await ensureMigrationTable(); + + const migrationFiles = (await readdir(migrationsDir)) + .filter((name) => /^\d+_.+\.sql$/.test(name)) + .sort(); - const applied = await appliedMigrations(); - for (const migration of migrationFiles) { - if (!applied.has(migration)) { - await runMigration(migration); + const applied = await appliedMigrations(); + for (const migration of migrationFiles) { + if (!applied.has(migration)) { + await runMigration(migration); + } } - } - await ensureModelCatalog(); - await normalizeRepoConfigs(); + console.log('Running catalog and config normalizations...'); + await query('DROP INDEX IF EXISTS repositories_owner_idx'); + await query('CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories (owner)'); + await ensureModelCatalog(); + await normalizeRepoConfigs(); + + await query('COMMIT'); + } catch (error) { + await query('ROLLBACK'); + throw error; + } console.log('Database migrations are up to date.'); } finally { + console.log('Releasing advisory lock...'); await query('SELECT pg_advisory_unlock($1)', [migrationLockId]); await sql.end(); } diff --git a/scripts/setup-cloudflare.js b/scripts/setup-cloudflare.js new file mode 100644 index 0000000..4bb5534 --- /dev/null +++ b/scripts/setup-cloudflare.js @@ -0,0 +1,568 @@ +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); + +function spawnAsync(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(process.platform === 'win32' ? `${command}.cmd` : command, args); + let stdout = '', stderr = ''; + child.stdout.on('data', d => stdout += d); + child.stderr.on('data', d => stderr += d); + child.on('close', code => { + if (code === 0) resolve({ stdout }); + else { + const err = new Error(`Command failed with code ${code}`); + err.stderr = stderr; + reject(err); + } + }); + child.on('error', err => reject(err)); + }); +} + +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 args = ['wrangler', 'kv', 'namespace', 'create', currentBinding]; + if (isPreview) args.push('--preview'); + const { stdout } = await spawnAsync('npx', args); + 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 spawnAsync('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) { + // Strip surrounding quotes, then unescape literal \n sequences + // (wrangler secrets must receive real newlines, not the two chars \ and n) + const raw = values.join('=').trim().replace(/^"|"$/g, ''); + env[key.trim()] = raw.replace(/\\n/g, '\n'); + } + } + } + } + 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 escapeJson = (str) => str.replace(/"/g, '\\"'); + + 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": "${escapeJson(appUrl)}"`); + + const callbackUrlRegex = /"AUTH_CALLBACK_URL":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(callbackUrlRegex, `"AUTH_CALLBACK_URL": "${escapeJson(appUrl)}/auth/github/callback"`); + + const botUsernameRegex = /"BOT_USERNAME":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(botUsernameRegex, `"BOT_USERNAME": "${escapeJson(botUsername)}"`); + + const githubAppSlugRegex = /"GITHUB_APP_SLUG":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(githubAppSlugRegex, `"GITHUB_APP_SLUG": "${escapeJson(githubAppSlug)}"`); + + const allowedUsersRegex = /"DASHBOARD_ALLOWED_USERS":\s*"[^"]+"/; + wranglerConfig = wranglerConfig.replace(allowedUsersRegex, `"DASHBOARD_ALLOWED_USERS": "${escapeJson(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); +}); diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..804fba1 --- /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 = 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/app.css b/src/client/app.css index d0a91e8..d0e60cb 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -16,38 +16,38 @@ :root { /* Surfaces - Pure & Crisp */ /* Surfaces - High-Contrast */ - --background: #ffffff; + --background: oklch(96.3% 0.003 286.3); /* #f4f4f5 */ --foreground: oklch(12% 0.02 115); - --card: #ffffff; + --card: oklch(100% 0 0); /* #ffffff */ --card-foreground: oklch(12% 0.02 115); - --popover: #ffffff; + --popover: oklch(100% 0 0); --popover-foreground: oklch(12% 0.02 115); /* Signature lime - darkened in light mode for AA accessibility on white */ --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: oklch(96.3% 0.003 286.3); + --secondary-foreground:oklch(27.4% 0.006 286.3); /* #27272a */ + --muted: oklch(96.3% 0.003 286.3); + --muted-foreground: oklch(55.1% 0.011 286.3); /* #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: oklch(90.9% 0.004 286.3); /* #e4e4e7 */ + --accent-foreground: oklch(20.5% 0.005 286.3); /* #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: oklch(90.9% 0.004 286.3); + --input: oklch(90.9% 0.004 286.3); --ring: oklch(72% 0.22 115); /* Radius */ - --radius: 0.5rem; + --radius: 0.75rem; --sidebar-width: 240px; --sidebar-collapsed-width: 72px; @@ -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); @@ -66,14 +66,14 @@ --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; - --code-fg: #27272a; - --code-border: #e4e4e7; + --code-bg: oklch(96.3% 0.003 286.3); + --code-fg: oklch(27.4% 0.006 286.3); + --code-border: oklch(90.9% 0.004 286.3); } /* ───────────────────────────────────────────────────── @@ -125,9 +125,9 @@ --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3); /* Code Blocks (Zinc) */ - --code-bg: #18181b; - --code-fg: #d4d4d8; - --code-border: #27272a; + --code-bg: oklch(20.5% 0.005 286.3); + --code-fg: oklch(86.5% 0.005 286.3); + --code-border: oklch(27.4% 0.006 286.3); } /* ───────────────────────────────────────────────────── @@ -173,11 +173,11 @@ --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-2xl: 32px; + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1.125rem; + --radius-2xl: 2rem; /* Fluid Typography Scale (Ratio: 1.25) */ --text-xs: 0.75rem; @@ -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 { @@ -550,9 +549,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); @@ -599,14 +598,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; } /* ───────────────────────────────────────────────────── @@ -657,19 +656,23 @@ } .app-shell-content { - --background: #ffffff; - --card: #ffffff; - --muted: #ffffff; - --popover: #ffffff; - --secondary: #ffffff; + --background: oklch(96.3% 0.003 286.3); /* #f4f4f5 */ + --card: oklch(100% 0 0); /* #ffffff */ + --muted: oklch(90.9% 0.004 286.3); /* #e4e4e7 */ + --popover: oklch(100% 0 0); + --secondary: oklch(88.5% 0.004 286.3); /* #e2e2e6 */ + --border: oklch(90.9% 0.004 286.3); /* #e4e4e7 */ + --input: oklch(90.9% 0.004 286.3); /* #e4e4e7 */ } .dark .app-shell-content { - --background: #09090b; - --card: #09090b; - --muted: #09090b; - --popover: #09090b; - --secondary: #09090b; + --background: oklch(18% 0.018 115); + --card: oklch(18% 0.018 115); + --muted: oklch(22% 0.02 115); + --popover: oklch(18% 0.018 115); + --secondary: oklch(26% 0.02 115); + --border: oklch(22% 0.02 115); + --input: oklch(22% 0.02 115); } .dashboard-sidebar-divider { @@ -865,3 +868,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: min(22rem, calc(100vw - 2rem)) !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/components/features/job-detail/comment-card.tsx b/src/client/components/features/job-detail/comment-card.tsx index 0e66c38..6edd77c 100644 --- a/src/client/components/features/job-detail/comment-card.tsx +++ b/src/client/components/features/job-detail/comment-card.tsx @@ -21,7 +21,7 @@ export function CommentCard({ comment, filePath }: CommentCardProps) { return (
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 56ae254..0bc5915 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -1,114 +1,220 @@ -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 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; +} + +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 ?? []; + const shortCommitSha = job.commitSha?.slice(0, 7) ?? 'unknown'; + return (
- {/* Details */} + + {/* ── Job details ── */} Job details - -
+ + + {/* Metadata grid */} +
{[ { label: 'Status', value: }, - { label: 'Verdict', value: job.verdict ? : }, + { 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}
))} +
-
Commit
+
Commit
- - {job.commitSha.slice(0, 7)} - - + {job.commitSha ? ( + + {shortCommitSha} + + + ) : ( + {shortCommitSha} + )}
+ {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 && (
-

Error

-

{job.errorMessage}

+

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

+

+ {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..fc794db 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,86 @@ 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 */}
+ className="h-[3px] rounded-full bg-primary-foreground/15 overflow-hidden" + role="progressbar" + aria-valuenow={isQueued ? 0 : pct} + aria-valuemin={0} + aria-valuemax={100} + aria-label={isQueued ? 'Review waiting in queue' : 'File review progress'} + > +
+
+ + {/* Active file + percent */} + {!isQueued && ( +
+
+ {prefixPath && ( + {prefixPath} + )} + {displayPath + ? {displayPath} + : + } +
+ {pct}% +
+ )}
); diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index b31dbce..685f363 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'; @@ -25,32 +24,38 @@ export type ModelRouteTier = { }; export type ModelRouteConfig = { - main: string; + main: string | null; fallbacks: string[]; 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[] = []) { + 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), + 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[]; hideLabels?: boolean; density?: ModelDensity; className?: string; @@ -59,25 +64,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); + 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')} /> 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 }); + }} + />
- updateTier(index, { model, fallbacks })} - />
- - ))} -
+ ))} +
+ )}
); } 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/components/features/stats/time-range-select.tsx b/src/client/components/features/stats/time-range-select.tsx index f82c81f..4bd7a50 100644 --- a/src/client/components/features/stats/time-range-select.tsx +++ b/src/client/components/features/stats/time-range-select.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import { Clock } from 'lucide-react'; import { Select } from '@client/components/ui/select'; import { cn } from '@client/lib/utils'; @@ -6,6 +7,7 @@ interface TimeRangeSelectProps { value: number; onValueChange: (value: number) => void; className?: string; + triggerStyle?: CSSProperties; } const timeRanges = [ @@ -15,7 +17,7 @@ const timeRanges = [ { label: 'Last 90 days', value: 90 }, ]; -export function TimeRangeSelect({ value, onValueChange, className }: TimeRangeSelectProps) { +export function TimeRangeSelect({ value, onValueChange, className, triggerStyle }: TimeRangeSelectProps) { const selectedRange = timeRanges.find((r) => r.value === value) || timeRanges[2]; return ( @@ -28,6 +30,7 @@ export function TimeRangeSelect({ value, onValueChange, className }: TimeRangeSe }))} leadingIcon={} triggerClassName={cn('w-44', className)} + triggerStyle={triggerStyle} /> ); } 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/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..a078763 100644 --- a/src/client/components/shared/jobs-table.tsx +++ b/src/client/components/shared/jobs-table.tsx @@ -8,6 +8,7 @@ import { import { StatusBadge } from '@client/components/ui/badge'; import { Skeleton } from '@client/components/shared/skeleton'; import { cn, fmtNumber } from '@client/lib/utils'; + import type { JobSummary } from '@shared/schema'; type Column = @@ -40,8 +41,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]', @@ -198,6 +199,7 @@ function JobMobileCard({ job, columns }: { job: JobSummary; columns: Column[] }) export function JobsTable({ jobs, loading, columns }: JobsTableProps) { const cols: Column[] = columns ?? DEFAULT_COLUMNS; const tableMinWidth = cols.length > 7 ? 'min-w-[980px]' : 'min-w-[720px]'; + const itemBgClass = 'bg-background'; return (
@@ -262,7 +264,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) { return ( {cols.includes('repo') && (
- + {job.repo.slice(0, 2).toUpperCase()}
@@ -293,23 +295,23 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) { {cols.includes('pr') && ( -
+
-
-
+
+
#{job.prNumber} {job.prTitle ?? 'Untitled PR'} @@ -407,7 +409,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) { > diff --git a/src/client/components/shared/page-header-actions.tsx b/src/client/components/shared/page-header-actions.tsx new file mode 100644 index 0000000..4fe9505 --- /dev/null +++ b/src/client/components/shared/page-header-actions.tsx @@ -0,0 +1,42 @@ +import { RefreshCw } from 'lucide-react'; +import { Button } from '@client/components/ui/button'; +import { TimeRangeSelect } from '@client/components/features/stats/time-range-select'; +import { useIsDarkMode } from '@client/hooks/use-is-dark-mode'; + +interface PageHeaderActionsProps { + days: number; + onDaysChange: (days: number) => void; + onRefresh: () => void; + refreshing: boolean; +} + +export function PageHeaderActions({ + days, + onDaysChange, + onRefresh, + refreshing, +}: PageHeaderActionsProps) { + const isDark = useIsDarkMode(); + const btnBg = isDark ? undefined : '#ffffff'; + + return ( + <> + + + + ); +} 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/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, opt.value === value); @@ -49,10 +60,12 @@ export function Select({ - + {options.map((option) => ( onValueChange(option.value)} className={cn( - 'cursor-pointer whitespace-nowrap', - value === option.value && 'bg-accent font-medium dark:bg-primary/[0.12]' + 'cursor-pointer whitespace-normal break-words py-2', + value === option.value && + 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary', )} > - {option.label} + {option.label} ))} diff --git a/src/client/components/ui/switch.tsx b/src/client/components/ui/switch.tsx index d15edf0..b0daa81 100644 --- a/src/client/components/ui/switch.tsx +++ b/src/client/components/ui/switch.tsx @@ -9,7 +9,7 @@ export interface SwitchProps extends Omit( ({ className, onCheckedChange, onChange, ...props }, ref) => { return ( -
+ ); + })} + )} + + {/* Footer */} + {!loading && ( +
+

+ {catalogRefreshing + ? 'Syncing model lists…' + : catalogRefreshedOnce + ? 'Synced this session' + : 'Loaded from database'} +

+
+ )} + + + {/* ── Global model strategy ───────────────────────────────────────────── */} + - {saving === 'global' - ? - : } + {saving === 'global' ? : } Save strategy - - -
+ } + > +
{!loading && globalConfig ? ( -
- -
+ ) : ( -
+
- +
)}
- + -
-
-
- - - -
-

Model usage quotas

-

- Provider rate limits and token capacity per model. -

+ {/* ── Models & Usage Limits ────────────────────────────────────────────── */} + + {dirtyConfigs.length > 0 && ( + + )} + +
+ } + > + {/* 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, [field]: parseOptionalLimit(e.target.value) }))} + /> +
+ ))} +
+
+
- + )} + + {/* Filters */} +
+ +
+ 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" - /> - - ))} + {/* Rate limits — compact pills */} +
+ {(['rpm', 'rpd', 'tpm'] as const).map(field => ( + + {field.toUpperCase()} {formatOptionalLimit(cfg[field])} + + ))} +
+ + {/* Actions */} +
+ + + + +
+ + {/* Expanded edit panel */} + {expanded && ( +
+
+ updateModel(cfg.modelId, { modelName: e.target.value })} + /> +
+
+ {(['rpm', 'rpd', 'tpm'] as const).map(field => { + const limitId = domId(`model-${field}`, cfg.modelId); + return ( +
+ {field.toUpperCase()} + updateQuota(cfg.modelId, field, parseOptionalLimit(e.target.value))} + /> +
+ ); + })} +
+
+
+ )} ); })} + + {filteredConfigs.length === 0 && ( +
+ No models match the current filters. +
+ )}
)} -
+ + + {/* ── 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 + + +
+ +
+
); } diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx index e8eb83a..ae77c7d 100644 --- a/src/client/pages/stats.tsx +++ b/src/client/pages/stats.tsx @@ -24,6 +24,7 @@ import { } from 'lucide-react'; import { TimeRangeSelect } from '@client/components/features/stats/time-range-select'; +import { PageHeaderActions } from '@client/components/shared/page-header-actions'; import { PageHeader } from '@client/components/layout/page-header'; import { Skeleton } from '@client/components/shared/skeleton'; import { Alert } from '@client/components/ui/alert'; @@ -471,19 +472,12 @@ export function StatsPage() { title="Review metrics" description="Daily review and comment activity for the selected range." actions={ - <> - - - + load(true)} + refreshing={refreshing} + /> } /> diff --git a/src/server/core/config.ts b/src/server/core/config.ts index 8ecc766..43eb8ca 100644 --- a/src/server/core/config.ts +++ b/src/server/core/config.ts @@ -1,4 +1,4 @@ -import { defaultRepoConfig, normalizeRepoModelConfig, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, normalizeRepoModelConfig, repoConfigSchema, type RepoConfig } from '@shared/schema'; import { REPO_CONFIG_CACHE_VERSION } from '@shared/config'; import type { AppBindings } from '@server/env'; import { getRepoConfigRecord, syncRepoConfig } from '@server/db/repo-configs'; @@ -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) { @@ -49,9 +38,14 @@ function hasRepoModelOverride(existing: Awaited): Promise { const cached = await env.APP_KV.get(GLOBAL_CONFIG_KEY, 'json'); - if (cached) return normalizeRepoModelConfig(cached as RepoConfig['model']); + if (cached) { + const parsed = repoConfigSchema.shape.model.safeParse(cached); + if (parsed.success) { + return normalizeRepoModelConfig(parsed.data); + } + } - return SERVER_DEFAULT_GLOBAL_CONFIG; + return EMPTY_GLOBAL_CONFIG; } export async function updateGlobalConfig(env: Pick, config: RepoConfig['model']) { 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/github.ts b/src/server/core/github.ts index 7d0ea18..28dee67 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -25,7 +25,12 @@ async function withRetry( return await fn(); } catch (error: any) { attempt++; + const isSecondaryRateLimit = error instanceof GitHubError && + error.status === 403 && + error.body?.toLowerCase().includes('secondary rate limit'); + const isRetryable = + isSecondaryRateLimit || (error instanceof GitHubError && (error.status === 429 || error.status >= 500)) || error.name === 'TimeoutError' || error.message.includes('timeout'); @@ -34,7 +39,7 @@ async function withRetry( throw error; } - const delay = Math.pow(2, attempt) * 1000; + const delay = isSecondaryRateLimit ? Math.pow(2, attempt) * 30000 : Math.pow(2, attempt) * 1000; logger.warn(`Retrying GitHub operation ${operation} (attempt ${attempt}/${maxRetries}) in ${delay}ms`, { status: error instanceof GitHubError ? error.status : undefined, error: error.message, @@ -87,6 +92,10 @@ export type GitHubReviewComment = { body: string; }; +type GitHubIssueLabel = { + name?: string; +}; + function installationCacheKey(installationId: string) { return `install:${installationId}`; } @@ -104,10 +113,17 @@ 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, '') .replace(/-----END (RSA )?PRIVATE KEY-----/g, '') + // Handle literal \n escape sequences (e.g. when the key is stored as a + // single-line string with \n instead of real newlines in wrangler secrets) + .replace(/\\n/g, '') .replace(/\s+/g, ''); const binary = atob(base64); @@ -370,7 +386,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; }); } @@ -378,7 +394,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', ); @@ -388,7 +404,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; } @@ -417,7 +433,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', @@ -450,7 +466,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', @@ -493,7 +509,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', @@ -507,7 +524,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', @@ -526,7 +543,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}`, ); } @@ -537,7 +554,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; } @@ -551,7 +568,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', @@ -573,7 +590,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', @@ -583,10 +600,36 @@ export class GitHubClient { }); } + async listIssueLabels(owner: string, repo: string, issueNumber: number) { + return withRetry(`listIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { + 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: GitHubIssueLabel) => 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])); + + const uniqueLabels = Array.from(new Set(labels.map(label => 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) { 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/job-recovery.ts b/src/server/core/job-recovery.ts new file mode 100644 index 0000000..c00c193 --- /dev/null +++ b/src/server/core/job-recovery.ts @@ -0,0 +1,71 @@ +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); +} + +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))); + } +} + +export function scheduleBestEffortJobMaintenance( + env: AppBindings, + executionCtx?: Pick, +) { + const task = runBestEffortJobMaintenance(env); + if (executionCtx) { + executionCtx.waitUntil(task); + } +} 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/model-output.ts b/src/server/core/model-output.ts index fc8ebc9..d2298d5 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_JSON_CHARS = 2_000; + +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) { return /"(findings|overall_explanation|overall_correctness|overall_confidence_score|summary)"\s*:/.test(input); } @@ -127,8 +134,13 @@ function extractJson(raw: string) { } } - // Truncated - return everything from first brace - return raw.slice(firstBrace).trim(); + // Truncated JSON: the closing brace(s) are missing. Append them so jsonrepair + // has a structurally complete (though incomplete-content) object to work with. + const partial = raw.slice(firstBrace).trim(); + let closing = ''; + if (inString) closing += '"'; + closing += '}'.repeat(Math.max(1, stack)); + return `${partial}${closing}`; } return raw.trim(); @@ -147,36 +159,37 @@ function coerceReviewNumber(value: unknown) { return undefined; } -function normalizeFinding(finding: any) { +function normalizeFinding(finding: unknown) { if (!finding || typeof finding !== 'object') return null; - if (isPlaceholderString(finding.title) || isPlaceholderString(finding.body)) return null; + const f = finding as Record; + if (isPlaceholderString(f.title) || isPlaceholderString(f.body)) return null; - const location = finding.code_location && typeof finding.code_location === 'object' ? finding.code_location : {}; + const location = f.code_location && typeof f.code_location === 'object' ? (f.code_location as Record) : {}; const line = coerceReviewNumber(location.line); - const start = coerceReviewNumber(location.line_range?.start); - const end = coerceReviewNumber(location.line_range?.end); - const priority = coerceReviewNumber(finding.priority); + const start = coerceReviewNumber(location.line_range && typeof location.line_range === 'object' ? (location.line_range as Record).start : undefined); + const end = coerceReviewNumber(location.line_range && typeof location.line_range === 'object' ? (location.line_range as Record).end : undefined); + const priority = coerceReviewNumber(f.priority); const codeLocation: Record = { - absolute_file_path: location.absolute_file_path || finding.path || '', + absolute_file_path: location.absolute_file_path || f.path || '', }; if (line !== undefined) { - codeLocation.line = Math.trunc(line); + codeLocation.line = Math.trunc(line as number); } if (start !== undefined || end !== undefined) { codeLocation.line_range = { - start: Math.trunc(start ?? end!), - end: Math.trunc(end ?? start!), + start: Math.trunc((start as number) ?? (end as number)!), + end: Math.trunc((end as number) ?? (start as number)!), }; } return { - ...finding, - title: finding.title || 'Code finding', - priority: priority === undefined ? undefined : Math.max(0, Math.min(3, Math.trunc(priority))), + ...f, + title: f.title || 'Code finding', + priority: priority === undefined ? undefined : Math.max(0, Math.min(3, Math.trunc(priority as number))), code_location: codeLocation, - confidence_score: typeof finding.confidence_score === 'number' - ? Math.max(0, Math.min(1, finding.confidence_score > 1 ? finding.confidence_score / 10 : finding.confidence_score)) + confidence_score: typeof f.confidence_score === 'number' + ? Math.max(0, Math.min(1, f.confidence_score > 1 ? f.confidence_score / 10 : f.confidence_score)) : undefined, }; } @@ -253,7 +266,13 @@ 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 }); + // Log a prefix of the raw response so we can diagnose what the model returned + // without bloating logs with 10k+ char dumps. + logger.error('Failed to extract JSON from model response', { + rawLength: raw.length, + rawPrefix: raw.slice(0, 500), + error: e instanceof Error ? e.message : String(e), + }); throw new Error('Could not find JSON root in model response.'); } @@ -269,26 +288,26 @@ 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: truncateJsonForLog(preprocessed), error: e }); } - let parsedJson: any; + let parsedJson: unknown; 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: truncateJsonForLog(repaired), error: e }); throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`); } let parsed: z.infer; try { - const findReviewObject = (arr: any[]): any | null => { + const findReviewObject = (arr: unknown[]): unknown | null => { // Priority 1: Has findings array and summary - const best = arr.find(i => i && typeof i === 'object' && Array.isArray(i.findings) && typeof i.summary === 'string'); + const best = arr.find(i => i && typeof i === 'object' && Array.isArray((i as Record).findings) && typeof (i as Record).summary === 'string'); if (best) return best; // Priority 2: Has findings array - const good = arr.find(i => i && typeof i === 'object' && Array.isArray(i.findings)); + const good = arr.find(i => i && typeof i === 'object' && Array.isArray((i as Record).findings)); if (good) return good; // Priority 3: Has review-like keys @@ -298,29 +317,31 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): { ); }; - const data = Array.isArray(parsedJson) ? (findReviewObject(parsedJson) || parsedJson[0] || {}) : parsedJson; + let data = Array.isArray(parsedJson) ? (findReviewObject(parsedJson) || parsedJson[0] || {}) : parsedJson; // Ensure essential keys exist to avoid schema validation errors if (data && typeof data === 'object') { - if (!data.findings) data.findings = []; - if (!data.overall_explanation) data.overall_explanation = 'No explanation provided.'; - if (!data.overall_correctness) data.overall_correctness = 'Uncertain'; + const obj = data as Record; + if (!obj.findings) obj.findings = []; + if (!obj.overall_explanation) obj.overall_explanation = 'No explanation provided.'; + if (!obj.overall_correctness) obj.overall_correctness = 'Uncertain'; // Handle confidence score hallucinations (0-1 range expected) - if (typeof data.overall_confidence_score === 'number') { - if (data.overall_confidence_score > 1) { + if (typeof obj.overall_confidence_score === 'number') { + if (obj.overall_confidence_score > 1) { // If they gave 1-10 scale, normalize it - data.overall_confidence_score = Math.min(data.overall_confidence_score / 10, 1); - } else if (data.overall_confidence_score < 0) { - data.overall_confidence_score = 0; + obj.overall_confidence_score = Math.min(obj.overall_confidence_score / 10, 1); + } else if (obj.overall_confidence_score < 0) { + obj.overall_confidence_score = 0; } } else { - data.overall_confidence_score = 0.5; + obj.overall_confidence_score = 0.5; } - if (Array.isArray(data.findings)) { - data.findings = data.findings.map(normalizeFinding).filter(Boolean); + if (Array.isArray(obj.findings)) { + obj.findings = obj.findings.map(normalizeFinding).filter(Boolean); } + data = obj; } parsed = fileReviewModelOutputSchema.parse(data); diff --git a/src/server/core/review.ts b/src/server/core/review.ts index de56f0b..03a4036 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -1,14 +1,15 @@ 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 } from '@server/db/file-reviews'; -import { completeJob, failJob, findExistingJobForHead, getJobForProcessing, insertJob, mapJob, startJobProcessing, completePreparationStep, supersedeOlderJobs, updateJobCheckRun, updateJobStep } from '@server/db/jobs'; +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'; 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 +17,96 @@ import { getWebhookDelivery } from '@server/db/webhook-deliveries'; type PersistedReviewJob = ReturnType; +export type ReviewJobRunResult = { action: 'ack' } | { action: 'retry'; delaySeconds: number }; + +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; +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) { + 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 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); +} + +function countsAsHandledFileReview(review: { file_status: string; error_msg: string | null }) { + 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); + 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)); +} + +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); } @@ -94,341 +185,424 @@ export function extractReviewRequest(input: { return null; } -export async function runReviewJob(env: AppBindings, message: ReviewJobMessage) { - let job: PersistedReviewJob; +export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): Promise { + const resolved = await resolveQueuedJob(env, message); + if (!resolved) { + return { action: 'ack' }; + } - if (message.jobId) { - const row = await getJobForProcessing(env, message.jobId); - if (!row) { - logger.warn(`Job not found for processing: ${message.jobId}`); - return; - } + 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) }; + } - job = mapJob(row); - if (job.status === 'superseded') { - logger.info(`Job ${job.id} is superseded, skipping processing.`); - 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, { jobId: job.id }); + const formatter = new FormatterService(env.APP_URL); + + try { + if (phase === 'prepare') { + await runPreparePhase(env, job, leaseOwner, github); + } else if (phase === 'finalize') { + await runFinalizePhase(env, job, leaseOwner, github, formatter); + } else { + await runReviewPhase(env, job, leaseOwner, github, model); } - if (job.status === 'running') { - logger.info(`Job ${job.id} is already running, skipping duplicate queue delivery.`); - 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' }; } - } else { - if (!message.eventName) { - logger.warn('Queue message ignored: missing eventName'); - return; + + 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, + }); + await enqueueJobPhase(env, job.id, phase, delaySeconds); + await releaseJobLease(env, job.id, leaseOwner); + return { action: 'ack' }; } - let eventName = message.eventName; - let payload = message.payload as GitHubWebhookPayload | undefined; + logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); + await failJobAndCheckRun(env, job, github, messageText); + await releaseJobLease(env, job.id, leaseOwner); + return { action: 'ack' }; + } +} - if (payload === undefined) { - const delivery = await getWebhookDelivery(env, message.deliveryId); - if (!delivery) { - logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`); - 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; + } - eventName = delivery.event_name; - payload = delivery.payload as GitHubWebhookPayload; - } + if (!message.eventName) { + logger.warn('Queue message ignored: missing eventName'); + return null; + } - if (!isSupportedGitHubWebhookEvent(eventName)) { - logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); - return; - } + let eventName = message.eventName; + let payload = message.payload as GitHubWebhookPayload | undefined; - 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 (payload === undefined) { + const delivery = await getWebhookDelivery(env, message.deliveryId); + if (!delivery) { + logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`); + return null; } - // 1. Load Repo Config - const repoConfig = await loadRepoConfig(env, { - installationId, - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); + eventName = delivery.event_name; + payload = delivery.payload as GitHubWebhookPayload; + } - if (repoConfig.enabled === false) { - logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); - return; - } + if (!isSupportedGitHubWebhookEvent(eventName)) { + logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); + return null; + } - // 2. Extract Review Request - const extracted = extractReviewRequest({ - eventName, - payload, - botUsername: env.BOT_USERNAME, - config: repoConfig.parsedJson, - }); + 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; + } - 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; - } + const repoConfig = await loadRepoConfig(env, { + installationId, + owner: payload.repository.owner.login, + repo: payload.repository.name, + }); - // 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 (repoConfig.enabled === false) { + logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); + 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; + 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.removeIssueLabelsIfPresent( + prPayload.repository.owner.login, + prPayload.repository.name, + prPayload.pull_request.number, + [labels.p1, labels.p2, labels.p3], + ); } - 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, - }); - - // 6. Supersede older jobs - await supersedeOlderJobs(env, { - installationId: resolved.installationId, - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - newJobId: job.id, - }); } + 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); - - let checkRunId = job.checkRunId; + 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, + }; + } - 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; + 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 pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); - const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + logger.info(`Duplicate terminal job found for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}, skipping.`); + 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; + 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 updateJobCheckRun(env, job.id, checkRun.id); - } +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 rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); - const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); + 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); + } - tracker.incrementSubrequests(1); - await completePreparationStep(env, job.id, files.length); + 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); - tracker.incrementSubrequests(1); - const preparedJob = await getJobForProcessing(env, job.id); - if (preparedJob?.status === 'superseded') { - throw new Error('JOB_SUPERSEDED'); - } + if (files.length === 0) { + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await enqueueJobPhase(env, job.id, 'finalize'); + return; + } - 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); - } + 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'); +} + +async function runReviewPhase( + env: AppBindings, + job: PersistedReviewJob, + leaseOwner: string, + github: GitHubService, + model: ModelService, +) { + if (!hasCompletedStep(job, 'Preparation')) { + await runPreparePhase(env, job, leaseOwner, github); + return; + } + + 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 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); + 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])); + + const reviewTasks: Array> = []; + + for (const file of files) { + const existingReview = currentReviews.get(file.path); + if (existingReview && countsAsHandledFileReview(existingReview)) { + continue; } - 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; + const inherited = parentReviews.get(file.path); + const reviewTask = async () => { + if (!inherited) { + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); + return; } - // 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'); - } + 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, resolveFailureModelProvider, existingReview); + } 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); } + }; - const existing = existingReviews.find((r) => r.file_path === file.path && r.file_status === 'done'); + reviewTasks.push(reviewTask()); + processedThisChunk += 1; - if (existing) { - reviewedComments.push(...(existing.parsed_comments as ParsedReviewComment[])); - fileSummaries.push({ - path: file.path, - summary: existing.file_summary ?? '', - verdict: existing.verdict ?? 'comment', - }); + if (processedThisChunk >= REVIEW_CHUNK_FILE_LIMIT || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { + break; + } + } - 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; - } + const results = await Promise.allSettled(reviewTasks); + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); - // 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}`, - }); - } + 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 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, - }); + 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; - reviewedComments.push(...response.parsed.comments); - fileSummaries.push({ - path: file.path, - summary: response.parsed.fileSummary, - verdict: response.parsed.verdict, - }); + if (completedCount >= files.length) { + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await enqueueJobPhase(env, job.id, 'finalize'); + return; + } - 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', - }); + 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'); +} + +async function reviewAndPersistFile( + env: AppBindings, + job: PersistedReviewJob, + file: ReturnType[number], + pr: Awaited>, + config: RepoConfig, + totalLineCount: number, + model: ModelService, + resolveFailureModelProvider: () => Promise, + previousReview?: { transient_error_count: number }, +) { + const startedAt = Date.now(); + const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; + try { + const response = await model.reviewFile({ + file, + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + config, + totalLineCount, + compactPrompt, + }); - newReviewsToInsert.push({ + 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'; + const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveFailureModelProvider(); + + if (isRetryableModelError(error)) { + const failureCount = await recordRetryableFileReviewFailure(env, job.id, { + filePath: file.path, + modelUsed: modelId, + modelProvider, + 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: config.model?.main ?? 'gemma-4-31b-it', - modelProvider: (config.model?.main ?? 'gemma-4-31b-it').startsWith('@cf/') ? 'cloudflare' : 'google', + modelUsed: modelId, + modelProvider, diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, @@ -438,137 +612,222 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage) durationMs: Date.now() - startedAt, verdict: null, fileSummary: null, - errorMessage, + errorMessage: finalError, + }); + logger.error(`File review failed permanently for ${file.path} after transient retries`, { + attempts: failureCount, + error: errorMessage, }); + 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); - } - 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.', + logger.warn(`File review deferred for ${file.path}; transient model/provider failure will retry later`, { + error: errorMessage, + attempts: failureCount, }); - throw new Error('Review stopped before all files were analyzed due to subrequest limits.'); + Object.defineProperty(error, 'retryAfterSeconds', { + value: retryableModelFailureDelaySeconds(failureCount), + configurable: true, + }); + throw error; } - 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'); - } + logger.error(`File review failed for ${file.path}`, { error }); - tracker.incrementSubrequests(1); - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + const isHardLimit = + errorMessage.toLowerCase().includes('subrequest') || + errorMessage.includes('4006') || + errorMessage.toLowerCase().includes('allocation'); - 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); - - // 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 (isHardLimit) { + throw error; } - const summaryResponse = await model.generateSummary({ - prTitle: pr.title ?? null, - verdict: verdictSummary.verdict, - fileSummaries, - config, + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'failed', + modelUsed: modelId, + modelProvider, + diffLineCount: file.lineCount, + diffInput: '', + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: Date.now() - startedAt, + verdict: null, + fileSummary: null, + errorMessage, }); + } +} - await updateJobStep(env, job.id, 'Generating Summary', { status: 'done' }); +async function runFinalizePhase( + env: AppBindings, + job: PersistedReviewJob, + leaseOwner: string, + github: GitHubService, + 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 formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME); + 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 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 hasFailures = fileSummaries.some((file) => file.verdict === 'failed'); + const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length; + const severityRanks: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; + const minRank = severityRanks[config.review.min_severity] ?? 4; + + let finalComments = reviewedComments.filter(c => (severityRanks[c.severity] ?? 4) <= minRank); + finalComments.sort((a, b) => (severityRanks[a.severity] ?? 4) - (severityRanks[b.severity] ?? 4)); + + const omittedCount = reviewedComments.length - Math.min(finalComments.length, config.review.max_comments); + if (finalComments.length > config.review.max_comments) { + finalComments = finalComments.slice(0, config.review.max_comments); + } - 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); - } - } + const verdictSummary = formatter.summarizeVerdict(finalComments, hasFailures); + await updateJobStep(env, job.id, 'Generating Summary', { status: 'done' }); + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); - await github.ensureLabel(job.owner, job.repo, label.name, label.color); - await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); - } + let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME); + + if (omittedCount > 0) { + formattedSummary += `\n\n> [!NOTE]\n> **${omittedCount} comments were omitted** from this review to reduce noise and respect the configured \`max_comments\` limit (${config.review.max_comments}). Showing the most critical issues.`; + } - await github.updateCheckRun(job.owner, job.repo, checkRunId, { + 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: finalComments.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]; + + 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]); + } + + 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.' : ''}`, + summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed after repeated provider outages.` : ''}`, }); + } - 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); + 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, + commentCount: finalComments.length, + totalInputTokens: fileInputTokens, + totalOutputTokens: fileOutputTokens, + summaryMarkdown: formattedSummary, + reviewId: review.id, + summaryModel: null, + errorMessage: partialErrorMessage, + }); + 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, delaySeconds); + 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..7a41254 100644 --- a/src/server/db/client.ts +++ b/src/server/db/client.ts @@ -5,6 +5,7 @@ import type { AppBindings } from '@server/env'; type DbEnv = Pick; type DbClient = { query(sqlText: string, params?: unknown[]): Promise; + transaction(fn: (tx: DbClient) => Promise): Promise; }; const dbStorage = new AsyncLocalStorage(); @@ -13,14 +14,28 @@ 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[]; }, + async transaction(fn: (tx: DbClient) => Promise) { + return (await sql.begin(async (t) => { + const txClient: DbClient = { + async query(sqlText: string, params: unknown[] = []) { + return (await t.unsafe(sqlText, params.map(normalizeParam) as any[], { prepare: false })) as U[]; + }, + async transaction(innerFn: (tx: DbClient) => Promise) { + // Nested transactions could use savepoints, but for now we just reuse the same txClient + return await innerFn(txClient); + } + }; + return await fn(txClient); + })) as T; + } }; } @@ -55,6 +70,10 @@ export async function queryRows(env: DbEnv, sqlText: string, params: unknown[ return getDb(env).query(sqlText, params); } +export async function queryTransaction(env: DbEnv, fn: (tx: DbClient) => Promise) { + return getDb(env).transaction(fn); +} + export function parseJsonColumn(value: T | string | null | undefined, fallback: T): T { if (value === null || value === undefined) return fallback; if (typeof value === 'string') return JSON.parse(value) as T; diff --git a/src/server/db/file-reviews.ts b/src/server/db/file-reviews.ts index bc99aac..641d4f9 100644 --- a/src/server/db/file-reviews.ts +++ b/src/server/db/file-reviews.ts @@ -1,6 +1,6 @@ import type { ParsedReviewComment } from '@shared/schema'; import type { AppBindings } from '@server/env'; -import { parseJsonColumn, queryRows } from './client'; +import { parseJsonColumn, queryRows, queryTransaction } from './client'; export async function insertFileReview( env: Pick, @@ -24,73 +24,250 @@ export async function insertFileReview( 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) - RETURNING id - `, - [ - input.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 queryTransaction(env, async (tx) => { + const [review] = await tx.query<{ id: string }>( + ` + 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) + RETURNING id + `, + [ + input.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, + ], + ); + + if (input.parsedComments.length > 0) { + const paths = input.parsedComments.map(c => c.path); + const lines = input.parsedComments.map(c => c.line ?? null); + const positions = input.parsedComments.map(c => c.position ?? null); + const severities = input.parsedComments.map(c => c.severity); + const categories = input.parsedComments.map(c => c.category); + const titles = input.parsedComments.map(c => c.title); + const bodies = input.parsedComments.map(c => c.body); + const codeSuggestions = input.parsedComments.map(c => c.codeSuggestion ?? null); + + await tx.query( + ` + 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, paths, lines, positions, severities, categories, titles, bodies, codeSuggestions] + ); + } + }); +} + +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; + }, +) { + await queryTransaction(env, async (tx) => { + const [review] = await tx.query<{ id: string }>( + ` + 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, + transient_error_count = 0 + 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, + ], + ); - if (input.parsedComments.length > 0) { - // Insert comments - // Using simple loop or unnest, unnest is more efficient for batch insert - const paths = input.parsedComments.map(c => c.path); - const lines = input.parsedComments.map(c => c.line ?? null); - const positions = input.parsedComments.map(c => c.position ?? null); - const severities = input.parsedComments.map(c => c.severity); - const categories = input.parsedComments.map(c => c.category); - const titles = input.parsedComments.map(c => c.title); - const bodies = input.parsedComments.map(c => c.body); - const codeSuggestions = input.parsedComments.map(c => c.codeSuggestion ?? null); + await tx.query('DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); - await queryRows( - env, + if (input.parsedComments.length > 0) { + await tx.query( + ` + 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 recordRetryableFileReviewFailure( + env: Pick, + jobId: string, + input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + durationMs: number | null; + errorMessage: string; + }, +) { + return await queryTransaction(env, async (tx) => { + const [review] = await tx.query<{ id: string; transient_error_count: number }>( ` - INSERT INTO review_comments ( - file_review_id, path, line, position, severity, category, title, body, code_suggestion + 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 ) - SELECT $1::uuid, * FROM UNNEST($2::text[], $3::int[], $4::int[], $5::text[], $6::text[], $7::text[], $8::text[], $9::text[]) + 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 `, - [review.id, paths, lines, positions, severities, categories, titles, bodies, codeSuggestions] + [ + jobId, + input.filePath, + input.modelUsed, + input.modelProvider ?? null, + input.diffLineCount, + input.diffInput, + input.durationMs, + input.errorMessage, + ], ); - } + + await tx.query('DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); + return review.transient_error_count; + }); } export async function getModelUsageStats(env: Pick) { @@ -158,84 +335,83 @@ export async function batchInsertFileReviews( const errorMessages = reviews.map(r => r.errorMessage); const modelProviders = reviews.map(r => r.modelProvider ?? null); - const insertedRows = await queryRows<{ id: string; file_path: 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 - ) - SELECT $1::uuid, * FROM UNNEST( - $2::text[], $3::text[], $4::text[], $5::int[], $6::text[], - $7::text[], $8::int[], $9::int[], $10::int[], $11::text[], - $12::text[], $13::text[], $14::real[], $15::text[], $16::text[] - ) - RETURNING id, file_path - `, - [ - jobId, filePaths, fileStatuses, modelsUsed, diffLineCounts, diffInputs, - rawAiOutputs, inputTokens, outputTokens, durationMs, verdicts, - fileSummaries, overallCorrectness, confidenceScores, errorMessages, modelProviders - ] - ); - - // 2. Prepare and insert comments for all reviews - const allComments: Array<{ - fileReviewId: string; - path: string; - line: number | null; - position: number | null; - severity: string; - category: string; - title: string; - body: string; - codeSuggestion: string | null; - }> = []; - - for (const review of reviews) { - const inserted = insertedRows.find(r => r.file_path === review.filePath); - if (!inserted || review.parsedComments.length === 0) continue; - - for (const comment of review.parsedComments) { - allComments.push({ - fileReviewId: inserted.id, - path: comment.path, - line: comment.line ?? null, - position: comment.position ?? null, - severity: comment.severity, - category: comment.category, - title: comment.title, - body: comment.body, - codeSuggestion: comment.codeSuggestion ?? null, - }); - } - } - - if (allComments.length > 0) { - await queryRows( - env, + await queryTransaction(env, async (tx) => { + const insertedRows = await tx.query<{ id: string; file_path: string }>( ` - INSERT INTO review_comments ( - file_review_id, path, line, position, severity, category, title, body, code_suggestion + 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 ) - SELECT * FROM UNNEST( - $1::uuid[], $2::text[], $3::int[], $4::int[], $5::text[], $6::text[], $7::text[], $8::text[], $9::text[] + SELECT $1::uuid, * FROM UNNEST( + $2::text[], $3::text[], $4::text[], $5::int[], $6::text[], + $7::text[], $8::int[], $9::int[], $10::int[], $11::text[], + $12::text[], $13::text[], $14::real[], $15::text[], $16::text[] ) + RETURNING id, file_path `, [ - allComments.map(c => c.fileReviewId), - allComments.map(c => c.path), - allComments.map(c => c.line), - allComments.map(c => c.position), - allComments.map(c => c.severity), - allComments.map(c => c.category), - allComments.map(c => c.title), - allComments.map(c => c.body), - allComments.map(c => c.codeSuggestion), + jobId, filePaths, fileStatuses, modelsUsed, diffLineCounts, diffInputs, + rawAiOutputs, inputTokens, outputTokens, durationMs, verdicts, + fileSummaries, overallCorrectness, confidenceScores, errorMessages, modelProviders ] ); - } + + const allComments: Array<{ + fileReviewId: string; + path: string; + line: number | null; + position: number | null; + severity: string; + category: string; + title: string; + body: string; + codeSuggestion: string | null; + }> = []; + + for (const review of reviews) { + const inserted = insertedRows.find(r => r.file_path === review.filePath); + if (!inserted || review.parsedComments.length === 0) continue; + + for (const comment of review.parsedComments) { + allComments.push({ + fileReviewId: inserted.id, + path: comment.path, + line: comment.line ?? null, + position: comment.position ?? null, + severity: comment.severity, + category: comment.category, + title: comment.title, + body: comment.body, + codeSuggestion: comment.codeSuggestion ?? null, + }); + } + } + + if (allComments.length > 0) { + await tx.query( + ` + INSERT INTO review_comments ( + file_review_id, path, line, position, severity, category, title, body, code_suggestion + ) + SELECT * FROM UNNEST( + $1::uuid[], $2::text[], $3::int[], $4::int[], $5::text[], $6::text[], $7::text[], $8::text[], $9::text[] + ) + `, + [ + allComments.map(c => c.fileReviewId), + allComments.map(c => c.path), + allComments.map(c => c.line), + allComments.map(c => c.position), + allComments.map(c => c.severity), + allComments.map(c => c.category), + allComments.map(c => c.title), + allComments.map(c => c.body), + allComments.map(c => c.codeSuggestion), + ] + ); + } + }); } @@ -262,6 +438,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 76e1944..c06b884 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) { @@ -70,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, @@ -88,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, @@ -366,6 +407,120 @@ 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 + ) + 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 + 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 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, + 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, delaySeconds = 0) { + await queryRows( + env, + ` + UPDATE jobs + 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, delaySeconds], + ); +} + export async function updateJobCheckRun(env: Pick, jobId: string, checkRunId: number) { await queryRows( env, @@ -391,14 +546,19 @@ export async function completeJob( reviewId: number | null; summaryModel: string | null; overallConfidenceScore?: number | null; + errorMessage?: string | null; }, ) { + const now = new Date().toISOString(); await queryRows( env, ` 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, @@ -408,7 +568,28 @@ export async function completeJob( review_id = $8, summary_model = $9, overall_confidence_score = $10, - error_msg = NULL + 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 `, [ @@ -421,7 +602,9 @@ export async function completeJob( input.summaryMarkdown, input.reviewId, input.summaryModel, - input.overallConfidenceScore ?? null + input.overallConfidenceScore ?? null, + input.errorMessage ?? null, + now ], ); } @@ -433,6 +616,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 +637,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, @@ -531,7 +728,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( @@ -580,6 +778,126 @@ export async function recoverStaleJobs( return rows.length; } +export async function recoverExpiredJobLeases( + env: Pick, + maxRecoveryCount = 3, + unleasedGraceSeconds = 300, +) { + 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 +914,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/db/model-configs.ts b/src/server/db/model-configs.ts index 7755cf1..453f51e 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; - rpm: number; - tpm: number; - rpd: number; - provider: string; + provider_id: string; + provider_name: string; + api_format: LlmApiFormat; + model_name: string; + rpm: number | null; + tpm: number | null; + rpd: number | null; 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,220 @@ 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 providerSlug = slugify(input.providerName); + 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 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 rowsToInsert: Array<{ + model_id: string; + provider_id: string; + model_name: string; + rpm: number | null; + tpm: number | null; + rpd: number | null; + 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: null, + tpm: null, + rpd: null, + 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 d591cb6..34dddf1 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 { @@ -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/index.ts b/src/server/index.ts index df53f8e..6b52af1 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 { runBestEffortJobMaintenance } 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,42 @@ 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 }); + try { + await runBestEffortJobMaintenance(env); + } catch (error) { + logger.error('Pre-batch maintenance task failed', error instanceof Error ? error : new Error(String(error))); } - } 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))); - } - // ── 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) { + logger.error('Invalid queue message schema; retrying so it can reach the DLQ', { + body: message.body, + error: parseResult.error.flatten(), + }); + message.retry(); + continue; + } - 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; + 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(); + } } try { - await runReviewJob(env, parseResult.data); - message.ack(); + await runBestEffortJobMaintenance(env); } catch (error) { - logger.error('Queue message processing failed; retrying', error instanceof Error ? error : new Error(String(error))); - message.retry(); + logger.error('Post-batch maintenance task failed', error instanceof Error ? error : new Error(String(error))); } - } }); }, } satisfies ExportedHandler; diff --git a/src/server/models/anthropic.ts b/src/server/models/anthropic.ts new file mode 100644 index 0000000..dab3da4 --- /dev/null +++ b/src/server/models/anthropic.ts @@ -0,0 +1,73 @@ +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 interface AnthropicResponse { + content?: Array<{ text?: string }>; + usage?: { + input_tokens?: number; + output_tokens?: number; + }; +} + +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.` }, + { role: 'assistant', content: '{' } + ], + 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 AnthropicResponse; + let rawText = Array.isArray(data.content) + ? data.content.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim() + : ''; + + if (!rawText && (!data.content || data.content.length === 0)) { + throw new Error('Anthropic provider returned an empty response.'); + } + + // Prepend the '{' that we pre-filled in the assistant message + rawText = '{' + rawText; + + 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..d8bfd45 --- /dev/null +++ b/src/server/models/catalog.ts @@ -0,0 +1,240 @@ +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', +]; + +interface OpenAIModelsResponse { + data?: Array<{ id?: unknown }>; +} + +interface AnthropicModelsResponse { + data?: Array<{ id?: unknown }>; +} + +interface GeminiModelsResponse { + models?: Array<{ + name?: unknown; + supportedGenerationMethods?: unknown; + }>; +} + +interface CloudflareModelItem { + id?: unknown; + name?: unknown; + model?: unknown; + model_id?: unknown; +} + +interface CloudflareModelsResponse { + result?: CloudflareModelItem[] | { data?: CloudflareModelItem[] }; + data?: CloudflareModelItem[]; +} + +function cleanGeminiModelName(name: string) { + return name.startsWith('models/') ? name.slice('models/'.length) : name; +} + +function extractOpenAiModels(data: OpenAIModelsResponse) { + return Array.isArray(data?.data) + ? data.data.map((item) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + : []; +} + +function extractAnthropicModels(data: AnthropicModelsResponse) { + return Array.isArray(data?.data) + ? data.data.map((item) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + : []; +} + +function extractGeminiModels(data: GeminiModelsResponse) { + if (!Array.isArray(data?.models)) return []; + return data.models + .filter((model) => Array.isArray(model?.supportedGenerationMethods) + ? model.supportedGenerationMethods.includes('generateContent') + : true) + .map((model) => 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() as OpenAIModelsResponse); + } + + 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() as AnthropicModelsResponse); + } + + 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`; + const response = await withTimeout('Google model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(url, { + signal, + headers: { + 'x-goog-api-key': apiKey, + }, + }), + ); + if (!response.ok) throw new Error(`Google model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractGeminiModels(await response.json() as GeminiModelsResponse); + } + + 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() as CloudflareModelsResponse); + return models.length > 0 ? models : CLOUDFLARE_TEXT_GENERATION_MODELS; +} + +function extractCloudflareModels(data: CloudflareModelsResponse) { + const items = Array.isArray(data?.result) + ? data.result + : typeof data?.result === 'object' && data.result !== null && 'data' in data.result && Array.isArray((data.result as { data?: unknown[] }).data) + ? (data.result as { data?: unknown[] }).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 b0f20cc..8436c75 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -1,19 +1,169 @@ 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 (600 s). */ -const CLOUDFLARE_TIMEOUT_MS = 600_000; +/** 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 = 8192; +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; + +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 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, + }); + 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(); + + if (Array.isArray(content)) { + const text = content + .map((part) => { + if (isText(part)) return part; + if (isRecord(part) && isText(part.text)) return part.text; + return ''; + }) + .join('') + .trim(); + return text || null; + } + + return null; +} + +function extractCloudflareText(result: unknown, model: string): string { + if (isText(result)) return result.trim(); + const response = getText(result, 'response'); + if (response) return response; + + 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 = 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) { + return synthesizeInconclusiveReview(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); + } + + if (finishReason) { + return synthesizeInconclusiveReview(model, `finish_reason=${String(finishReason)}`); + } + + return synthesizeInconclusiveReview(model, '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, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, + providerName = 'Cloudflare', ): Promise { - const maxRetries = 2; - let lastError: any; + const maxRetries = CLOUDFLARE_MAX_RETRIES; + let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { let timer: ReturnType | undefined; @@ -35,32 +185,49 @@ 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: 4096, + 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, ]); 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); + 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', + provider: providerName, }; } catch (error) { lastError = error; const errorMsg = error instanceof Error ? error.message : String(error); + + 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); + } + 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 bdd1d30..974c7ba 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -1,33 +1,71 @@ 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 (120 s). */ -const GOOGLE_TIMEOUT_MS = 120_000; +/** Max wall-clock time allowed for a single Google AI Studio call. */ +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 isRetryableGeminiStatus(status: number) { + return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; +} + +function isPrivateIP(hostname: string) { + const privateRanges = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^localhost$/, + /^::1$/, + ]; + return privateRanges.some((regex) => regex.test(hostname)); +} + +function isValidPublicUrl(urlString: string) { + try { + const url = new URL(urlString); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + const hostname = url.hostname; + if (hostname === 'metadata.google.internal' || hostname === '100.100.100.200') return false; + if (isPrivateIP(hostname)) return false; + return true; + } catch { + return false; + } +} 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}`); + + if (config.baseUrl && !isValidPublicUrl(config.baseUrl)) { + throw new ProviderRequestError(config.providerName ?? 'Google', 400, 'Invalid provider base URL.'); + } + const startTime = Date.now(); - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${env.GEMINI_API_KEY}`; - const maxRetries = 2; - let lastError: any; + 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++) { try { 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, @@ -46,7 +84,7 @@ export async function reviewWithGoogle( ], generationConfig: { responseMimeType: 'application/json', - maxOutputTokens: 4096, + maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, }, }), }), @@ -54,24 +92,22 @@ export async function reviewWithGoogle( if (!response.ok) { const errorText = await response.text(); - const isRateLimit = response.status === 429; - const isRetryable = !isRateLimit && response.status >= 500; + const message = providerErrorMessage(errorText); + const isRetryable = isRetryableGeminiStatus(response.status); - logger.error(`Google request failed with ${response.status}`, { - error: errorText, + const logData = { + error: message, attempt, - willRetry: isRetryable && attempt < maxRetries - }); - - if (isRateLimit) { - throw new Error(`Google request failed with ${response.status}: ${errorText}`); - } - + willRetry: isRetryable && attempt < maxRetries, + }; 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; @@ -87,7 +123,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 { @@ -95,7 +131,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..11f4540 --- /dev/null +++ b/src/server/models/openai.ts @@ -0,0 +1,122 @@ +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; + +export interface OpenAIResponse { + choices?: Array<{ + message?: { + content?: string | Array<{ text?: string }>; + }; + }>; + output_text?: string; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + input_tokens?: number; + output_tokens?: number; + }; +} + +function extractOpenAiText(data: OpenAIResponse) { + 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 ''; +} + +function isPrivateIP(hostname: string) { + const privateRanges = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^localhost$/, + /^::1$/, + ]; + return privateRanges.some((regex) => regex.test(hostname)); +} + +function isValidPublicUrl(urlString: string) { + try { + const url = new URL(urlString); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + + const hostname = url.hostname; + if (hostname === 'metadata.google.internal' || hostname === '100.100.100.200') { + return false; + } + if (isPrivateIP(hostname)) { + return false; + } + return true; + } catch { + return false; + } +} + +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}`); + + if (!isValidPublicUrl(config.baseUrl)) { + throw new ProviderRequestError(config.providerName, 400, 'Invalid provider base URL.'); + } + + 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 OpenAIResponse; + 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..ece7258 100644 --- a/src/server/models/types.ts +++ b/src/server/models/types.ts @@ -3,5 +3,40 @@ 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 unknown; + if (typeof parsed === 'object' && parsed !== null) { + const obj = parsed as Record; + let message: unknown; + + if (typeof obj.error === 'object' && obj.error !== null) { + message = (obj.error as Record).message ?? obj.error; + } else { + message = obj.message ?? obj.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/prompts/file-review.ts b/src/server/prompts/file-review.ts index 9a2846f..55c12b6 100644 --- a/src/server/prompts/file-review.ts +++ b/src/server/prompts/file-review.ts @@ -2,7 +2,7 @@ import type { RepoConfig } from '@shared/schema'; import type { FileDiff } from '@server/core/diff'; import { getLanguageForFile } from './languages'; -export const fileReviewSystemPrompt = `You are a world-class software engineer performing a precise, security-focused code review. +export const fileReviewSystemPromptBase = `You are a world-class software engineer performing a precise, security-focused code review. Your goal is to identify bugs, security vulnerabilities, performance bottlenecks, and quality issues in the provided diff. ### STRICT RULES: @@ -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 {{MAX_COMMENTS}} findings. Prioritize the most critical and severe issues (P0/P1) first. Keep each body under 160 words. +8. If there are no material issues, return an empty findings array and a short explanation. ### SCHEMA FORMAT: { @@ -34,9 +36,10 @@ Your goal is to identify bugs, security vulnerabilities, performance bottlenecks Identify security risks such as XSS, SQLi, CSRF, insecure randomness, and potential data leaks immediately.`; -export function buildFileReviewSystemPrompt(languagePersona?: string) { +export function buildFileReviewSystemPrompt(config: RepoConfig['review'], languagePersona?: string) { const persona = languagePersona ? ` as ${languagePersona}` : ''; - return `You are a world-class professional senior code reviewer${persona}. ${fileReviewSystemPrompt}`; + const prompt = fileReviewSystemPromptBase.replace('{{MAX_COMMENTS}}', config.max_comments.toString()); + return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; } export function buildFileReviewPrompts(input: { @@ -47,8 +50,7 @@ export function buildFileReviewPrompts(input: { }) { const languageInfo = getLanguageForFile(input.file.path); const rules = input.config.custom_rules.length > 0 ? input.config.custom_rules.map((rule) => `- ${rule}`).join('\n') : '- None'; - - const systemPrompt = buildFileReviewSystemPrompt(languageInfo?.persona); + const systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona); const languageGuidelines = languageInfo ? `Language: ${languageInfo.language}\nSpecific Guidelines:\n${languageInfo.guidelines.map(g => `- ${g}`).join('\n')}` : 'Language: Generic\nSpecific Guidelines: Follow general best practices.'; @@ -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 1125d79..e25fd35 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -1,13 +1,30 @@ import { Hono } from 'hono'; +import type { Context } from 'hono'; 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 { 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; + } catch { + return undefined; + } +} export function createJobsRouter() { const app = new Hono(); app.get('/', async (c) => { + scheduleBestEffortJobMaintenance(c.env, getExecutionContext(c)); + const rawQuery = c.req.query(); const query = jobsQuerySchema.parse(rawQuery); @@ -16,12 +33,30 @@ export function createJobsRouter() { }); app.get('/:id', async (c) => { + scheduleBestEffortJobMaintenance(c.env, getExecutionContext(c)); + const job = await getJobDetail(c.env, c.req.param('id')); if (!job) { return jsonError('Job not found.', 404); } - return c.json({ job }); + const etag = jobEtag(job); + const lastModified = new Date(job.updatedAt).toUTCString(); + if (c.req.header('if-none-match') === etag) { + return new Response(null, { + status: 304, + headers: { + ETag: etag, + 'Last-Modified': lastModified, + }, + }); + } + + 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) => { @@ -30,6 +65,17 @@ export function createJobsRouter() { return jsonError('Job not found.', 404); } const source = mapJob(rawSource); + let configSnapshot; + try { + const currentConfig = await loadRepoConfig(c.env, { + installationId: source.installationId, + owner: source.owner, + repo: source.repo, + }); + configSnapshot = currentConfig?.parsedJson ?? defaultRepoConfig; + } catch (e) { + configSnapshot = defaultRepoConfig; + } const job = await insertJob(c.env, { installationId: source.installationId, @@ -43,7 +89,7 @@ export function createJobsRouter() { trigger: 'retry', headRef: rawSource.head_ref, baseRef: rawSource.base_ref, - configSnapshot: source.configSnapshot ?? defaultRepoConfig, + configSnapshot, retryOfJobId: source.id, }); @@ -60,6 +106,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/api/models.ts b/src/server/routes/api/models.ts index 1a09283..8d8423e 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -1,23 +1,60 @@ 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, + getLlmProvider, + 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 optionalLimitSchema = positiveIntegerSchema.nullable(); 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({ - rpm: positiveIntegerSchema, - tpm: positiveIntegerSchema, - rpd: positiveIntegerSchema, - provider: providerSchema, + providerId: providerIdSchema, + modelName: z.string().trim().min(1), + 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( @@ -31,14 +68,121 @@ 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 providerCanBeEnabled(apiFormat: z.infer, encryptedApiKey: string | null | undefined) { + return apiFormat === 'cloudflare-workers-ai' || Boolean(encryptedApiKey); +} + +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 +199,181 @@ 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; + } + + 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, { + 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; + 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' + ? 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; + } + + 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, { + 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); + + switch (config.apiFormat) { + case 'gemini': + response = await reviewWithGoogle({ apiKey, baseUrl: config.baseUrl, providerName: config.providerName }, config.modelName, input); + break; + case 'openai': + response = await reviewWithOpenAI({ + apiKey, + baseUrl: config.baseUrl || 'https://api.openai.com/v1', + providerName: config.providerName, + }, config.modelName, input); + break; + case 'anthropic': + response = await reviewWithAnthropic({ apiKey, baseUrl: config.baseUrl, providerName: config.providerName }, config.modelName, input); + break; + default: + return jsonError(`Unsupported API format: ${config.apiFormat}`, 400); + } + } + + 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 +384,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/routes/webhook.ts b/src/server/routes/webhook.ts index f1f7dc6..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'); @@ -115,6 +113,7 @@ export function createWebhookRouter() { await c.env.REVIEW_QUEUE.send({ jobId: job.id, deliveryId, + phase: 'prepare', requestId: c.get('requestId'), }); @@ -130,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/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/src/server/services/model.ts b/src/server/services/model.ts index afff3eb..144a9a2 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -1,23 +1,45 @@ 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'; +import { truncateFileDiff } from '../core/diff'; import type { RepoConfig } from '@shared/schema'; 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', }; -function isCloudflareModel(model: string) { - return model.startsWith('@cf/'); +export class RetryableModelError extends Error { + readonly retryable = true; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'RetryableModelError'; + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { + value: cause, + writable: true, + configurable: true, + }); + } + } +} + +export function isRetryableModelError(error: unknown) { + return Boolean(error && typeof error === 'object' && 'retryable' in error && error.retryable === true); } function normalizeModel(model: string) { @@ -38,8 +60,73 @@ 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) {} + constructor( + private env: AppBindings, + private tracker?: TokenTracker, + private options: { jobId?: string } = {}, + ) {} + + private providerUnavailableKey(providerId: string) { + return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${providerId}` : null; + } + + 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 ${providerId}`, { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + private async markProviderUnavailable(providerId: string, reason: string) { + const key = this.providerUnavailableKey(providerId); + 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 ${providerId}`, { + error: error instanceof Error ? error.message : String(error), + }); + } + } private selectModel(params: { totalLineCount: number; @@ -48,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) { @@ -69,26 +148,78 @@ 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]; + 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 }; } - 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: { @@ -97,9 +228,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, }); @@ -109,20 +247,32 @@ export class ModelService { }); const modelsToTry = [primary, ...fallbacks]; - let lastError: any; - const unavailableProviders = new Set(); + let lastError: unknown; + let lastTransientError: unknown; + let sawTransientFailure = false; for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && unavailableProviders.has('cloudflare')) { - logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable`); + 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; } let attempts = 0; - const maxAttempts = 2; + const maxAttempts = 1; 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); @@ -133,19 +283,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'); + if (resolved.apiFormat === 'cloudflare-workers-ai' && isCloudflareAllocationError(error)) { + await this.markProviderUnavailable(resolved.providerId, 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 @@ -159,6 +316,15 @@ export class ModelService { } } + if (sawTransientFailure) { + 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}`, + retryCause, + ); + } + throw lastError; } @@ -171,16 +337,28 @@ export class ModelService { const { primary, fallbacks } = this.selectModel({ totalLineCount: 0, config: params.config }); const modelsToTry = [primary, ...fallbacks]; - let lastError: any; - const unavailableProviders = new Set(); + let lastError: unknown; + let lastTransientError: unknown; + let sawTransientFailure = false; for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && unavailableProviders.has('cloudflare')) { - logger.warn(`Skipping Cloudflare summary model ${currentModel} because Cloudflare AI allocation is unavailable`); + 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), }); @@ -190,15 +368,28 @@ export class ModelService { } return response; - } catch (error: any) { + } catch (error) { lastError = error; - if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - unavailableProviders.add('cloudflare'); + if (isTransientModelFailure(error)) { + sawTransientFailure = true; + lastTransientError = error; } - logger.warn(`Summary model ${currentModel} failed`, { error: error.message || 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) }); } } + if (sawTransientFailure) { + 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}`, + retryCause, + ); + } + throw lastError; } } diff --git a/src/server/worker-env.d.ts b/src/server/worker-env.d.ts index e5014d6..1a20668 100644 --- a/src/server/worker-env.d.ts +++ b/src/server/worker-env.d.ts @@ -1,39 +1,40 @@ /* 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: 63b433e2d7525f4fc91fc4ed25ea92e2) +// 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; + LLM_CONFIG_ENCRYPTION_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; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types @@ -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/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 cf12a90..095cd85 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(); @@ -73,10 +74,12 @@ export const reviewConfigSchema = z.object({ skip_files: z .array(z.string().min(1)) .default(['**/*.lock', 'dist/**', 'build/**', '.next/**', '*.generated.*', 'coverage/**']), - max_files: z.number().int().min(1).max(100).default(15), + max_files: z.number().int().min(1).max(100).default(100), large_file_threshold_lines: z.number().int().min(1).max(5_000).default(200), max_diff_lines_per_file: z.number().int().min(1).max(5_000).default(800), max_total_diff_chars: z.number().int().min(1).max(500_000).default(150_000), + max_comments: z.number().int().min(1).max(150).default(10), + min_severity: z.enum(reviewSeverities).default('nit'), focus: z.array(z.enum(reviewCategories)).default([...reviewCategories]), custom_rules: z.array(z.string().min(1)).default([]), labels: labelsSchema.default({ @@ -103,10 +106,12 @@ export const repoConfigSchema = z.object({ ignore_drafts: true, mention_trigger: '@codra-app', skip_files: ['**/*.lock', 'dist/**', 'build/**', '.next/**', '*.generated.*', 'coverage/**'], - max_files: 15, + max_files: 100, large_file_threshold_lines: 200, max_diff_lines_per_file: 800, max_total_diff_chars: 150_000, + max_comments: 10, + min_severity: 'nit', focus: [...reviewCategories], custom_rules: [], labels: { @@ -122,7 +127,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( @@ -136,8 +141,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: [], }), }); @@ -145,8 +150,9 @@ 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(), + payload: z.unknown().optional(), installationId: z.string().min(1).optional(), owner: z.string().min(1).optional(), repo: z.string().min(1).optional(), @@ -183,6 +189,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(), @@ -311,8 +319,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) => ({ @@ -335,15 +347,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().url().nullable(), + enabled: z.boolean(), + hasApiKey: z.boolean(), + createdAt: dateStringSchema, + updatedAt: dateStringSchema, +}); + export const modelConfigSchema = z.object({ modelId: z.string(), - rpm: z.number().int(), - tpm: z.number().int(), - rpd: z.number().int(), - provider: z.string(), + providerId: z.string().uuid(), + providerName: z.string(), + apiFormat: z.enum(llmApiFormats), + modelName: z.string(), + rpm: z.number().int().nullable(), + tpm: z.number().int().nullable(), + rpd: z.number().int().nullable(), updatedAt: dateStringSchema, }); +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 8d9ed3c..27e3d15 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'; @@ -10,15 +10,14 @@ import type { AuthSessionResponse, JobDetailResponse, JobsResponse, + ModelConfigsResponse, RepoConfigsResponse, StatsResponse, UpdatesEmailResponse, } from '@shared/api'; -import { createTestEnv, hasConfiguredTestDatabaseUrl } from './helpers'; +import { createTestEnv, saveTestProviderApiKey } from './helpers'; import { vi } from 'vitest'; -const dbIt = hasConfiguredTestDatabaseUrl() ? it : it.skip; - function mockGitHubProfile(login = 'devarshishimpi') { return { id: 42, @@ -117,7 +116,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 +263,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 +294,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 +348,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); @@ -386,6 +385,211 @@ 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('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 ${Date.now()}`, + 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); + 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')) { + return Response.json({ + success: false, + errors: [{ code: 10000, message: 'Authentication error' }], + messages: [], + result: null, + }, { status: 403 }); + } + return Response.json({ + models: [ + { + name: `models/${discoveredModelName}`, + 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; + 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([]); + }); + + 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); @@ -428,7 +632,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 +657,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 +685,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 +734,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()}`; @@ -547,6 +751,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(); @@ -568,6 +773,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/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/helpers.ts b/test/helpers.ts index a698918..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(); @@ -49,34 +51,29 @@ 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 }); } } -// 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; } +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; +} + +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 ( - usableEnvValue(process.env.TEST_DATABASE_URL) ?? - TEST_DATABASE_URL - ); + return requiredEnv('TEST_DATABASE_URL'); } export function hasConfiguredTestDatabaseUrl() { @@ -96,25 +93,38 @@ 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'), + 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'); }, + get CF_ACCOUNT_ID() { return unusedEnv('CF_ACCOUNT_ID'); }, + get CF_DLQ_ID() { return unusedEnv('CF_DLQ_ID'); }, ...overrides, }; } +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 22f522d..00dfea6 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -1,8 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { ModelService } from '@server/services/model'; -import { createTestEnv } from './helpers'; +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, saveTestProviderApiKey } 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({ @@ -23,4 +30,498 @@ describe('ModelService', () => { expect(requestedModel).toBe('@cf/moonshotai/kimi-k2.6'); 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('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: { + 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, + }); + + 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 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({ + 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.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( + { apiKey: '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({ + 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(1); + }); + + it('tries the smaller Google fallback after the primary Google model fails', async () => { + let cloudflareCalls = 0; + 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({ + 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() { + 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, + }); + await saveTestProviderApiKey(env); + 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(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-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'); + }); + + 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: 100, + large_file_threshold_lines: 200, + max_diff_lines_per_file: 800, + max_total_diff_chars: 150_000, + max_comments: 10, + min_severity: 'nit', + 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); + }); + + 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, + }); + await saveTestProviderApiKey(env); + 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(); + await saveTestProviderApiKey(env); + 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(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;'); + 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(); + await saveTestProviderApiKey(env); + 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/resumable-queue.spec.ts b/test/resumable-queue.spec.ts new file mode 100644 index 0000000..c494817 --- /dev/null +++ b/test/resumable-queue.spec.ts @@ -0,0 +1,293 @@ +import worker from '@server/index'; +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'; + +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('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', + 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'); + }); + + 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', () => { + 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..2e4676e 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -1,8 +1,10 @@ import { runReviewJob } from '@server/core/review'; 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'; const sha = (char: string) => char.repeat(40); @@ -26,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 }; @@ -61,24 +64,43 @@ vi.mock('@server/services/model', () => { async generateSummary() { return { modelUsed: 'sum-model', + provider: 'google', rawText: '{"summary": "test"}', + inputTokens: 3, + outputTokens: 2, }; } } - 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 +126,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 +155,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 +182,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 +221,95 @@ 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('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`; @@ -228,7 +331,7 @@ dbDescribe('Review Flow Lifecycle', () => { configSnapshot: defaultRepoConfig, }); - await runReviewJob(env, { + await runAndDrain({ deliveryId: 'delivery-duplicate', eventName: 'pull_request', payload: { @@ -248,5 +351,215 @@ 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); + + 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);' }, + { path: 'src/three.ts', content: 'console.log(3);' }, + ]), + ); + 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, 3); + 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(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(3); + + 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'); + 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, + }); + 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' }); + 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'); + 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(); + summarySpy.mockRestore(); + getDiffSpy.mockRestore(); + }, REVIEW_FLOW_TIMEOUT_MS); }); diff --git a/test/settings.spec.ts b/test/settings.spec.ts new file mode 100644 index 0000000..cbea8a4 --- /dev/null +++ b/test/settings.spec.ts @@ -0,0 +1,29 @@ +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', + fallbacks: [], + size_overrides: [ + { + max_lines: 300, + model: 'gemma-4-31b-it', + fallbacks: [], + }, + ], + }); + + expect(config.fallbacks).toEqual([]); + expect(config.size_overrides[0].fallbacks).toEqual([]); + }); +}); 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..965e603 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', @@ -116,11 +114,44 @@ 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(); }); - dbIt('acknowledges unsupported GitHub events without queueing review work', async () => { + it('rejects 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, + ); + + 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 () => { const rawPayload = createMockPRWebhook({ action: 'opened', repository: { name: `repo-${Date.now()}-check-suite`, owner: { login: 'test-owner' } }, @@ -153,7 +184,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' } } 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..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 } ] @@ -83,7 +85,7 @@ "GITHUB_APP_WEBHOOK_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", - "GEMINI_API_KEY", + "LLM_CONFIG_ENCRYPTION_KEY", "CF_API_TOKEN", "CF_ACCOUNT_ID" ]