diff --git a/.dev.vars.example b/.dev.vars.example index da963bf..d0865c6 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -16,8 +16,7 @@ GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" # --- Cloudflare API --- -# Required permissions: Queues Edit for DLQ actions, Workers AI Read for -# Cloudflare model catalog discovery. +# Required permission: Workers AI Read for Cloudflare model catalog discovery. CF_ACCOUNT_ID="REPLACE_WITH_YOUR_CLOUDFLARE_ACCOUNT_ID" CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN" @@ -26,6 +25,13 @@ APP_URL="http://localhost:8787" AUTH_CALLBACK_URL="http://localhost:8787/auth/github/callback" ENVIRONMENT="development" +# --- Telemetry (optional) --- +# Anonymous aggregate usage stats are sent to https://codra.run/api/telemetry by default. +# Set TELEMETRY_DISABLED to "true" (or "1") to opt out entirely. +# TELEMETRY_DISABLED="true" +# TELEMETRY_API_URL="https://codra.run/api/telemetry" +# TELEMETRY_SECRET="REPLACE_TO_OVERRIDE_DEFAULT_TELEMETRY_SECRET" + # --- 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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..05a7337 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed0bd40..56df09a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: verify: name: Verify Stability @@ -46,10 +49,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 cache: 'npm' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..313d1dd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + strategy: + matrix: + language: ['javascript-typescript'] + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8e43a12 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +## v0.9.4 + +### What's New + +- **Job Cancellation & Deletion:** Review jobs can now be cancelled and deleted directly, with improved job status handling. +- **Telemetry Opt-Out:** Anonymous telemetry now ships with a clear opt-out and support for overriding the telemetry secret. +- **Concurrency Controls:** Model calls now respect configurable concurrency limits, with enhanced timeout handling to keep reviews from stalling. +- **Custom File Matchers:** Diff parsing and the review flow were refactored to support custom file matchers. +- **Review Performance Settings:** New settings and UI for tuning review performance. +- **Motion UI:** Smooth scrolling (Lenis) and new motion-based sidebar navigation, tabs, and select components. +- **New Installs Experience:** Improved UI and skeleton loaders for new installs. + +### Improvements + +- Added in-memory token caching for GitHub requests to reduce redundant API calls. +- Job continuation tracking added so long-running reviews resume more reliably under concurrency limits. +- Improved handling of subrequest budget limits during review job processing. +- Enhanced review job concurrency handling and caching mechanisms. +- Improved model service caching, timeout handling, and review settings API, with added tests for concurrency limits. +- Enhanced CI configuration, select component accessibility, and optimized stats queries. +- Refactored stats page and model configurations. +- Removed dead letter queue (DLQ) functionality and related dead code. +- Updated font imports and improved the jobs search bar UI, with added stale repo cleanup. +- Bumped vulnerable packages. + +### Bug Fixes + +- Fixed a failing Google API test case. +- Fixed `onMouseEnter` typecheck issue. +- Fixed sidebar active button colors. + +**Full Changelog**: https://github.com/devarshishimpi/codra/commits/v0.9.4 + +## v0.9.2 + +### What's New + +- **UI Redesign:** Full visual overhaul of the dashboard, landing page, and auth flows with an updated color system, improved dark mode, and a cleaner layout overall. +- **Code Splitting:** All pages now use `React.lazy` for async loading, reducing initial bundle size and improving load times. +- **Custom LLM Providers:** Manage custom OpenAI-compatible API providers directly from the dashboard. Rate limits are optional. No more hardcoded keys in wrangler config. +- **Cloudflare Setup Script:** A new Node.js script automates the full Cloudflare deployment setup, making self-hosting significantly easier. +- **Increased Review Capacity:** Max files processed per review raised from 15 to 100. + +### Improvements + +- Review jobs are now resumable and lease-aware, stalled reviews can recover automatically. +- Added retry logic for transient model provider failures. +- Optimized job polling with ETag caching and adaptive delays. +- Improved settings UI, error reporting, and API robustness across the board. +- Added GitHub Actions CI with a disposable test environment for the full test suite. + +### Bug Fixes + +- Fixed `APP_PRIVATE_KEY` parsing for single-line strings with literal `\n` sequences. +- Fixed database migration failures on existing deployments. +- Fixed duplicate `/api/auth/updates-email` calls on page load. +- Fixed file review status bar rendering on the dashboard. + +**Full Changelog**: https://github.com/devarshishimpi/codra/commits/v0.9.2 diff --git a/README.md b/README.md index 19bb877..56fa3de 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ 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). +> **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 @@ -38,7 +38,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - **Review with repository context**: Codra checks pull request diffs for correctness, security, performance, maintainability, and repo-specific patterns. - **Configure each repository**: Tune triggers, skipped paths, draft handling, mention reviews, labels, custom rules, and review budgets from the dashboard. - **Route models deliberately**: Use global defaults, per-repo model chains, fallbacks, and size-based overrides for larger pull requests. -- **Operate the system**: Inspect job history, PR findings, webhook deliveries, queue failures, DLQ replay, model usage, and dashboard stats. +- **Operate the system**: Inspect job history, PR findings, webhook deliveries, model usage, and dashboard stats. ## Features @@ -46,7 +46,6 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - Mention-triggered reviews for on-demand analysis - Inline GitHub review comments plus summary reviews and check run updates - Queue-backed processing through Cloudflare Queues -- Dead letter queue inspection, replay, and purge workflows - GitHub OAuth dashboard authentication - External PostgreSQL storage through Cloudflare Hyperdrive - Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models @@ -59,14 +58,14 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts 3. A review job is stored in PostgreSQL and queued on Cloudflare Queues. 4. The Worker consumes the job, fetches the PR diff, runs model review passes, and formats findings. 5. Codra posts inline comments and a summary review back to GitHub. -6. The dashboard keeps the job history, findings, logs, stats, and replay tools available for operators. +6. The dashboard keeps the job history, findings, logs, and stats available for operators. ## Stack - **Worker**: Cloudflare Workers, Hono, Wrangler - **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts - **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV -- **Queues**: Cloudflare Queues with DLQ workflows +- **Queues**: Cloudflare Queues and Workflows - **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers - **GitHub**: GitHub App webhooks, checks, reviews, and OAuth - **Quality**: TypeScript, Zod, Vitest, Playwright browser tests diff --git a/SECURITY.md b/SECURITY.md index ae4bbf6..3b794cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ Only the latest version of Codra is currently supported with security updates. I | Version | Supported | | ------- | ------------------ | -| >=0.9.x | :white_check_mark: | -| < 0.9.0 | :x: | +| >=0.9.4 | :white_check_mark: | +| < 0.9.4 | :x: | ## Reporting a Vulnerability diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index 7d63055..2f097c4 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -615,26 +615,6 @@ ON CONFLICT (model_id) DO UPDATE SET 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; diff --git a/db/migrations/002_jobs_async_review.sql b/db/migrations/002_jobs_async_review.sql new file mode 100644 index 0000000..9ea269b --- /dev/null +++ b/db/migrations/002_jobs_async_review.sql @@ -0,0 +1,25 @@ +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS workflow_instance_id UUID; +CREATE INDEX IF NOT EXISTS idx_jobs_workflow_instance_id ON jobs(workflow_instance_id); + +CREATE TABLE IF NOT EXISTS global_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT INTO global_settings (key, value) VALUES + ('review_concurrency_level', 'medium'), + ('review_max_comments', '10') +ON CONFLICT (key) DO NOTHING; + +ALTER TABLE model_configs DROP COLUMN IF EXISTS rpm; +ALTER TABLE model_configs DROP COLUMN IF EXISTS tpm; +ALTER TABLE model_configs DROP COLUMN IF EXISTS rpd; + +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS continuation_count INT NOT NULL DEFAULT 0; + +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS async_request_id TEXT; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS async_model TEXT; + +ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; +ALTER TABLE jobs ADD CONSTRAINT jobs_status_check + CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded', 'cancelled', 'stopped')); diff --git a/package-lock.json b/package-lock.json index 9909c30..2cffbcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codra", - "version": "0.9.0", + "version": "0.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codra", - "version": "0.9.0", + "version": "0.9.4", "license": "AGPL-3.0-only", "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -18,7 +18,9 @@ "clsx": "^2.1.1", "hono": "^4.12.12", "jsonrepair": "^3.13.3", + "lenis": "^1.3.25", "lucide-react": "^1.8.0", + "motion": "^12.42.2", "picomatch": "^4.0.4", "postgres": "^3.4.9", "react": "^19.2.5", @@ -34,7 +36,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.14.3", + "@cloudflare/vitest-pool-workers": "^0.16.20", "@tailwindcss/vite": "^4.2.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -45,8 +47,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "@vitest/browser": "^4.1.4", - "@vitest/browser-playwright": "^4.1.4", + "@vitest/browser": "^4.1.9", + "@vitest/browser-playwright": "^4.1.9", "chalk": "^5.6.2", "concurrently": "^9.2.1", "jsdom": "^29.0.2", @@ -56,7 +58,7 @@ "tailwindcss": "^4.2.2", "typescript": "^6.0.2", "vite": "^8.0.8", - "vitest": "^4.1.4", + "vitest": "^4.1.9", "wrangler": "^4.81.1" } }, @@ -119,13 +121,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -134,9 +136,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -144,9 +146,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -200,17 +202,17 @@ } }, "node_modules/@cloudflare/vitest-pool-workers": { - "version": "0.14.9", - "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.14.9.tgz", - "integrity": "sha512-XLJTOd+3A3BB6vPzD7gi1LTVKFSVge9HhGWXnLouPzySphMLbg+6xXELykFxZKyqP1AEjT2tc28AkBjM9GteFQ==", + "version": "0.16.20", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.16.20.tgz", + "integrity": "sha512-buw0YgsAMT7s60wcmyxbtciEJjMJzKcWzayDMPhWaqMqfQzW+0WPLV67Lobn4C80nkNQhYocEJPnrEhLWnOf+A==", "dev": true, "license": "MIT", "dependencies": { - "cjs-module-lexer": "^1.2.3", - "esbuild": "0.27.3", - "miniflare": "4.20260421.0", - "wrangler": "4.84.1", - "zod": "^3.25.76" + "cjs-module-lexer": "1.2.3", + "esbuild": "0.28.1", + "miniflare": "4.20260625.0", + "wrangler": "4.105.0", + "zod": "3.25.76" }, "peerDependencies": { "@vitest/runner": "^4.1.0", @@ -218,67 +220,6 @@ "vitest": "^4.1.0" } }, - "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", - "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { - "version": "4.84.1", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.84.1.tgz", - "integrity": "sha512-Xe1S/Bik7pNdtdJ+asHsEZC2dX9k3WxYn2BbxFtOrrLVxN/LKi750zsrjX41jSAk00M/O1l7jzyQV4sQqw8ftg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.16.0", - "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260421.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260421.1" - }, - "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=20.3.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260421.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, "node_modules/@cloudflare/vitest-pool-workers/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -290,9 +231,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260421.1.tgz", - "integrity": "sha512-DLU5ZTZ1VHeZZnj0PuVJEMHKGisfLe2XShyImP5P/PPj/m/t7CLEJmPiI7FMxvT7ynArkckJl7m+Z5x7u4Kkdw==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz", + "integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==", "cpu": [ "x64" ], @@ -307,9 +248,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260421.1.tgz", - "integrity": "sha512-Trotq3xRAkIcpC505WoxM8+kIH4JIvOJCNuRatyHcz9uF5S+ukgiVUFUlM+GIjw1uCM/Bda2St+vSniX1RZdpw==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz", + "integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==", "cpu": [ "arm64" ], @@ -324,9 +265,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260421.1.tgz", - "integrity": "sha512-938QjUv0z+QqK6BAvgwX/lCIZ2b224ZXoXtGTbhyNVMhB+mt4Dj24cj9qca4ekNXjVM7uTKp1yOHZO97fVSacw==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz", + "integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==", "cpu": [ "x64" ], @@ -341,9 +282,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260421.1.tgz", - "integrity": "sha512-YI4+mLfwnJcKJ+iPyxzx+tp2Jy4o29BxBPSQGZxl/AZyvZ9eTKsmNZmtjEiT4i3O/M0tdO/B/d9ESDHbRCs2rQ==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz", + "integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==", "cpu": [ "arm64" ], @@ -358,9 +299,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260421.1.tgz", - "integrity": "sha512-q1SFgwlNH9lFmw74vh7EJbJtduo92Nx51mNOfd3/u6pux6AldcwRviYzKEEv3FEbtv6OBB7J8D5f8vtZj7Z6Sg==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz", + "integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==", "cpu": [ "x64" ], @@ -399,9 +340,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -443,9 +384,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "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==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", "dev": true, "funding": [ { @@ -459,7 +400,7 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", + "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "engines": { @@ -494,9 +435,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "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==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", "dev": true, "funding": [ { @@ -539,21 +480,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -562,9 +503,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -573,9 +514,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -590,9 +531,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -607,9 +548,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -624,9 +565,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -641,9 +582,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -658,9 +599,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -675,9 +616,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -692,9 +633,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -709,9 +650,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -726,9 +667,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -743,9 +684,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -760,9 +701,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -777,9 +718,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -794,9 +735,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -811,9 +752,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -828,9 +769,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -845,9 +786,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -862,9 +803,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -879,9 +820,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -896,9 +837,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -913,9 +854,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -930,9 +871,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -947,9 +888,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -964,9 +905,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -981,9 +922,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -998,9 +939,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1659,14 +1600,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1678,9 +1619,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -1704,6 +1645,16 @@ "kleur": "^4.1.5" } }, + "node_modules/@poppinss/colors/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@poppinss/dumper": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", @@ -1737,18 +1688,18 @@ "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "license": "MIT" }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -1766,15 +1717,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1791,28 +1742,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1825,9 +1758,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1840,25 +1773,25 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -1875,28 +1808,10 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1909,16 +1824,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1936,18 +1851,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1965,9 +1880,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1980,14 +1895,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2005,12 +1920,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2023,29 +1938,29 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -2062,40 +1977,22 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2113,13 +2010,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2137,13 +2034,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2161,12 +2057,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2183,39 +2079,21 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2233,35 +2111,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", + "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -2279,12 +2134,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2297,23 +2152,23 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", + "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2330,28 +2185,10 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2364,13 +2201,13 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2383,12 +2220,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2401,12 +2238,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2419,9 +2256,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2434,12 +2271,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2452,12 +2289,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2470,12 +2307,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -2493,9 +2330,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, "node_modules/@reduxjs/toolkit": { @@ -2535,9 +2372,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -2552,9 +2389,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -2569,9 +2406,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -2586,9 +2423,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -2603,9 +2440,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -2620,9 +2457,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -2640,9 +2477,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], @@ -2660,9 +2497,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -2680,9 +2517,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -2700,9 +2537,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -2720,9 +2557,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -2740,9 +2577,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -2757,9 +2594,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], @@ -2767,18 +2604,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -2793,9 +2630,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "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==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -2830,9 +2667,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", "dev": true, "license": "CC0-1.0" }, @@ -2849,49 +2686,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@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" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -2906,9 +2743,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -2923,9 +2760,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -2940,9 +2777,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -2957,9 +2794,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -2974,9 +2811,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], @@ -2994,9 +2831,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], @@ -3014,9 +2851,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -3034,9 +2871,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], @@ -3054,9 +2891,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3072,11 +2909,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -3084,9 +2921,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -3101,9 +2938,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -3118,15 +2955,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -3222,9 +3059,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -3369,9 +3206,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3386,9 +3223,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3417,19 +3254,19 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -3449,15 +3286,15 @@ } }, "node_modules/@vitest/browser": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.7.tgz", - "integrity": "sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.9.tgz", + "integrity": "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/mocker": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -3468,18 +3305,18 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.7" + "vitest": "4.1.9" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.7.tgz", - "integrity": "sha512-OlTlJej7YN6VwV7zJJoNeaCsctF+JXpzpZ4oBHUbrQFfIq+0KW2f07rprCLh9N/zRIZ0v4Mchn1QDDmWMUhPKw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.9.tgz", + "integrity": "sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.7", - "@vitest/mocker": "4.1.7", + "@vitest/browser": "4.1.9", + "@vitest/mocker": "4.1.9", "tinyrainbow": "^3.1.0" }, "funding": { @@ -3487,7 +3324,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.7" + "vitest": "4.1.9" }, "peerDependenciesMeta": { "playwright": { @@ -3496,16 +3333,16 @@ } }, "node_modules/@vitest/expect": { - "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==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3514,13 +3351,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3541,9 +3378,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -3554,13 +3391,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -3568,14 +3405,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3584,9 +3421,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -3594,13 +3431,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3619,16 +3456,13 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -3767,9 +3601,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true, "license": "MIT" }, @@ -3829,6 +3663,34 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3869,15 +3731,15 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.8.4", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -3893,15 +3755,31 @@ "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==", + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?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" @@ -4201,9 +4079,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4238,16 +4116,16 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", "dev": true, "license": "MIT" }, "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", "license": "MIT", "workspaces": [ "docs", @@ -4255,9 +4133,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4268,32 +4146,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -4345,9 +4223,9 @@ "license": "MIT" }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4378,6 +4256,33 @@ } } }, + "node_modules/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -4616,9 +4521,9 @@ } }, "node_modules/hono": { - "version": "4.12.22", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", - "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4850,24 +4755,55 @@ } }, "node_modules/jsonrepair": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.14.0.tgz", - "integrity": "sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==", + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.14.1.tgz", + "integrity": "sha512-NpGgMhmzG/fajkBEFlS9jZvMSGDvc2xN/9wNCHZ+Nx32GZfLRELU6UE6dQkebvrQUct9S+7bvnpX29NB36Qbdw==", "license": "ISC", "bin": { "jsonrepair": "bin/cli.js" } }, "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "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/lenis": { + "version": "1.3.25", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.25.tgz", + "integrity": "sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5169,9 +5105,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5179,9 +5115,9 @@ } }, "node_modules/lucide-react": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", - "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", + "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6081,58 +6017,67 @@ } }, "node_modules/miniflare": { - "version": "4.20260421.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260421.0.tgz", - "integrity": "sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw==", + "version": "4.20260625.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", + "integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260421.1", - "ws": "8.18.0", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260625.1", + "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/miniflare/node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" + "node": ">=22.0.0" } }, - "node_modules/miniflare/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, + "node_modules/motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", "license": "MIT", - "engines": { - "node": ">=10.0.0" + "dependencies": { + "framer-motion": "^12.42.2", + "tslib": "^2.4.0" }, "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "bufferutil": { + "@emotion/is-prop-valid": { "optional": true }, - "utf-8-validate": { + "react": { + "optional": true + }, + "react-dom": { "optional": true } } }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6150,9 +6095,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -6169,15 +6114,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/onetime": { "version": "7.0.0", @@ -6196,9 +6144,9 @@ } }, "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==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", "dev": true, "license": "MIT", "dependencies": { @@ -6218,52 +6166,6 @@ "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", @@ -6336,13 +6238,13 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -6355,9 +6257,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6378,9 +6280,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -6434,19 +6336,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/pretty-format/node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6468,20 +6357,10 @@ "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", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -6499,30 +6378,30 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT", "peer": true }, @@ -6624,9 +6503,9 @@ } }, "node_modules/react-router": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", - "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6646,12 +6525,12 @@ } }, "node_modules/react-router-dom": { - "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==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "license": "MIT", "dependencies": { - "react-router": "7.15.1" + "react-router": "7.18.0" }, "engines": { "node": ">=20.0.0" @@ -6684,9 +6563,9 @@ } }, "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", + "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", "license": "MIT", "workspaces": [ "www" @@ -6699,7 +6578,7 @@ "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", + "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" @@ -6858,9 +6737,9 @@ } }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/restore-cursor": { @@ -6881,13 +6760,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6897,83 +6776,23 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@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": "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" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, - "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", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7004,9 +6823,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7068,9 +6887,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -7180,18 +6999,20 @@ } }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "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": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/stringify-entities": { @@ -7209,16 +7030,32 @@ } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "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": "^5.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi/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/strip-indent": { @@ -7286,9 +7123,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, @@ -7320,9 +7157,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -7330,9 +7167,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -7357,22 +7194,22 @@ } }, "node_modules/tldts": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.1.1.tgz", - "integrity": "sha512-VuvOq9QVVdzQyIwynB0MRZlEup+u5BD62FjgmKvRDFO8u1RgAzpeg7Qd70hUmrxwkkecqoz1N6t1yGMygx7rnA==", + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz", + "integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.1.1" + "tldts-core": "^7.4.5" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.1.1.tgz", - "integrity": "sha512-v9zYcyFEAJBeyG7g4+y/HFL9i2cHqpV+9cHohNZIhA6xjO2MSVgijFgx6quQaRBDzM5FT8fs5NPjsNITOhlCzg==", + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz", + "integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==", "dev": true, "license": "MIT" }, @@ -7463,9 +7300,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -7693,17 +7530,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -7719,7 +7556,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7786,19 +7623,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@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", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7826,12 +7663,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.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", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -7951,9 +7788,9 @@ } }, "node_modules/workerd": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260421.1.tgz", - "integrity": "sha512-zTYD+xFR4d7TUCxsyl7FTPth9a8CDgk8pM7xUWbJxo0SGUx+2e5C7Q5LrramBZwmuAErtzXmOjlQ15PtkPAhZA==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz", + "integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -7964,31 +7801,31 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260421.1", - "@cloudflare/workerd-darwin-arm64": "1.20260421.1", - "@cloudflare/workerd-linux-64": "1.20260421.1", - "@cloudflare/workerd-linux-arm64": "1.20260421.1", - "@cloudflare/workerd-windows-64": "1.20260421.1" + "@cloudflare/workerd-darwin-64": "1.20260625.1", + "@cloudflare/workerd-darwin-arm64": "1.20260625.1", + "@cloudflare/workerd-linux-64": "1.20260625.1", + "@cloudflare/workerd-linux-arm64": "1.20260625.1", + "@cloudflare/workerd-windows-64": "1.20260625.1" } }, "node_modules/wrangler": { - "version": "4.94.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.94.0.tgz", - "integrity": "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew==", + "version": "4.105.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.105.0.tgz", + "integrity": "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260521.0", + "esbuild": "0.28.1", + "miniflare": "4.20260625.0", "path-to-regexp": "6.3.0", - "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", - "workerd": "1.20260521.1" + "workerd": "1.20260625.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, @@ -7996,10 +7833,10 @@ "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260521.1" + "@cloudflare/workers-types": "^4.20260625.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -8007,181 +7844,81 @@ } } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { - "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" - ], + "node_modules/wrangler/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "Apache-2.0", + "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/wrangler/node_modules/miniflare": { - "version": "4.20260521.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260521.0.tgz", - "integrity": "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg==", + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260521.1", - "ws": "8.20.1", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrangler/node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/wrangler/node_modules/workerd": { - "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", - "bin": { - "workerd": "bin/workerd" + "dependencies": { + "color-convert": "^2.0.1" }, "engines": { - "node": ">=16" + "node": ">=8" }, - "optionalDependencies": { - "@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" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrangler/node_modules/ws": { - "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==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/ws": { @@ -8262,6 +7999,34 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 0210a1f..5801706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codra", - "version": "0.9.2", + "version": "0.9.4", "description": "Open-source code review engine", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -28,7 +28,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.14.3", + "@cloudflare/vitest-pool-workers": "^0.16.20", "@tailwindcss/vite": "^4.2.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -39,8 +39,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "@vitest/browser": "^4.1.4", - "@vitest/browser-playwright": "^4.1.4", + "@vitest/browser": "^4.1.9", + "@vitest/browser-playwright": "^4.1.9", "chalk": "^5.6.2", "concurrently": "^9.2.1", "jsdom": "^29.0.2", @@ -50,7 +50,7 @@ "tailwindcss": "^4.2.2", "typescript": "^6.0.2", "vite": "^8.0.8", - "vitest": "^4.1.4", + "vitest": "^4.1.9", "wrangler": "^4.81.1" }, "dependencies": { @@ -63,7 +63,9 @@ "clsx": "^2.1.1", "hono": "^4.12.12", "jsonrepair": "^3.13.3", + "lenis": "^1.3.25", "lucide-react": "^1.8.0", + "motion": "^12.42.2", "picomatch": "^4.0.4", "postgres": "^3.4.9", "react": "^19.2.5", diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index bff796f..8a29dc8 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -271,15 +271,6 @@ async function ensureModelCatalog() { await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID'); await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT'); - await query('ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL'); - await query('ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL'); - await query('ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL'); - await query(` - UPDATE model_configs - SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now() - WHERE rpm = 1 AND tpm = 1 AND rpd = 1 - `); - await query( ` UPDATE model_configs mc @@ -317,14 +308,11 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) - SELECT $1, 10, 131072, 300, 'cloudflare', p.id, $1, now() + INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at) + SELECT $1, 'cloudflare', p.id, $1, now() FROM llm_providers p WHERE p.name = 'Cloudflare' ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, provider = EXCLUDED.provider, provider_id = EXCLUDED.provider_id, model_name = EXCLUDED.model_name, @@ -335,41 +323,10 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) - SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() + INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at) + SELECT '@cf/zai-org/glm-4.7-flash', 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() FROM llm_providers p WHERE p.name = 'Cloudflare' - ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - 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, diff --git a/scripts/setup-cloudflare.js b/scripts/setup-cloudflare.js index 4bb5534..4464c97 100644 --- a/scripts/setup-cloudflare.js +++ b/scripts/setup-cloudflare.js @@ -305,19 +305,6 @@ async function main() { // 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'); @@ -331,21 +318,6 @@ async function main() { } } - 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 @@ -481,14 +453,6 @@ async function main() { 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.'); diff --git a/src/client/app.css b/src/client/app.css index d0e60cb..d06acd0 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -1,7 +1,11 @@ -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap'); @import "tailwindcss"; +/* Make `dark:` utilities follow the app's `.dark` class (theme toggle) + instead of the OS `prefers-color-scheme` default. */ +@custom-variant dark (&:where(.dark, .dark *)); + /* ───────────────────────────────────────────────────── Easing tokens ───────────────────────────────────────────────────── */ @@ -134,7 +138,7 @@ Tailwind v4 theme tokens (@theme inline = dynamic) ───────────────────────────────────────────────────── */ @theme inline { - --font-sans: 'Space Grotesk', 'Segoe UI', system-ui, sans-serif; + --font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --color-background: var(--background); @@ -723,19 +727,21 @@ will-change: transform; } -.dashboard-sidebar-action:hover, -.dashboard-sidebar-action:focus-visible { - background-color: color-mix(in oklch, oklch(100% 0 0) 10%, transparent) !important; - transform: translate3d(0, -2px, 0) !important; +/* Collapsed sidebar only: SharedLayoutBg pill handles background */ +.dashboard-sidebar-collapsed .dashboard-sidebar-action:hover, +.dashboard-sidebar-collapsed .dashboard-sidebar-action:focus-visible { + background-color: transparent !important; + transform: none !important; } -.dark .dashboard-sidebar-action:hover, -.dark .dashboard-sidebar-action:focus-visible { - background-color: color-mix(in oklch, oklch(100% 0 0) 10%, transparent) !important; +/* Collapsed active button hovered: deep green */ +.dashboard-sidebar-collapsed .dashboard-sidebar-action[aria-current="page"]:hover, +.dashboard-sidebar-collapsed .dashboard-sidebar-action[aria-current="page"]:focus-visible { + background-color: oklch(30% 0.35 115 / 0.35) !important; } -.dashboard-sidebar-action:active { - transform: translate3d(0, 0, 0) !important; +.dashboard-sidebar-collapsed .dashboard-sidebar-action:active { + transform: none !important; } .dashboard-sidebar-action-icon, @@ -756,11 +762,29 @@ transition-timing-function: var(--ease-out-quart); } +/* Expanded sidebar: SharedLayoutBg pill is the sole hover affordance. + No transform, no background — the pill handles everything. */ +.dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:hover, +.dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:focus-visible { + background-color: transparent !important; + transform: none !important; +} + +.dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:active { + transform: none !important; +} + .dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:hover .dashboard-sidebar-action-icon, .dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:focus-visible .dashboard-sidebar-action-icon, .dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:hover .dashboard-sidebar-action-label, .dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action:focus-visible .dashboard-sidebar-action-label { - transform: translate3d(4px, 0, 0) !important; + transform: none !important; +} + +/* Active button hovered: show a slightly stronger lime tint instead of the zinc pill */ +.dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action[aria-current="page"]:hover, +.dashboard-sidebar:not(.dashboard-sidebar-collapsed) .dashboard-sidebar-action[aria-current="page"]:focus-visible { + background-color: oklch(30% 0.35 115 / 0.35) !important; } .dashboard-sidebar-tooltip:not(.dashboard-sidebar-action-label) { @@ -768,10 +792,6 @@ } @media (min-width: 1024px) { - .dashboard-sidebar-collapsed .dashboard-sidebar-action:hover .dashboard-sidebar-action-icon, - .dashboard-sidebar-collapsed .dashboard-sidebar-action:focus-visible .dashboard-sidebar-action-icon { - transform: scale(1.12) !important; - } .dashboard-sidebar-collapsed .dashboard-sidebar-tooltip { position: absolute !important; @@ -887,8 +907,7 @@ gap: 0.625rem !important; padding: 0.75rem 0.875rem !important; border-radius: 0.625rem !important; - border-width: 1px !important; - border-style: solid !important; + border: none !important; font-family: var(--font-sans) !important; font-size: 0.8125rem !important; line-height: 1.45 !important; @@ -899,7 +918,6 @@ /* light defaults (overridden per-variant below) */ background: oklch(99.5% 0.004 115) !important; - border-color: oklch(88% 0.008 115) !important; color: oklch(15% 0.02 115) !important; /* smooth entrance */ @@ -908,7 +926,6 @@ .dark .codra-toast { background: oklch(13% 0.018 115) !important; - border-color: oklch(22% 0.022 115) !important; color: oklch(94% 0.006 115) !important; box-shadow: 0 6px 24px oklch(0% 0 0 / 0.5), @@ -967,64 +984,9 @@ background: oklch(28% 0.022 115) !important; } -/* ── SUCCESS ─────────────────────────────────────── */ -.codra-toast-success { - background: oklch(98.5% 0.045 115) !important; - border-color: oklch(82% 0.16 115) !important; - color: oklch(28% 0.10 115) !important; -} - -.dark .codra-toast-success { - background: oklch(16% 0.08 115) !important; - border-color: oklch(32% 0.14 115) !important; - color: oklch(90% 0.18 115) !important; -} - -.codra-toast-success .codra-toast-description { - color: oklch(38% 0.10 115) !important; - opacity: 0.85 !important; -} - -.dark .codra-toast-success .codra-toast-description { - color: oklch(72% 0.12 115) !important; - opacity: 0.9 !important; -} - -/* ── ERROR ───────────────────────────────────────── */ -.codra-toast-error { - background: oklch(98.5% 0.03 25) !important; - border-color: oklch(80% 0.14 25) !important; - color: oklch(32% 0.14 25) !important; -} - -.dark .codra-toast-error { - background: oklch(15% 0.07 25) !important; - border-color: oklch(35% 0.14 25) !important; - color: oklch(85% 0.08 25) !important; -} - -.codra-toast-error .codra-toast-description { - color: oklch(42% 0.12 25) !important; - opacity: 0.85 !important; -} - -.dark .codra-toast-error .codra-toast-description { - color: oklch(68% 0.10 25) !important; - opacity: 0.9 !important; -} - -/* ── LOADING ─────────────────────────────────────── */ -.codra-toast-loading { - background: oklch(98% 0.004 115) !important; - border-color: oklch(86% 0.010 115) !important; - color: oklch(20% 0.020 115) !important; -} - -.dark .codra-toast-loading { - background: oklch(14% 0.020 115) !important; - border-color: oklch(24% 0.025 115) !important; - color: oklch(88% 0.008 115) !important; -} +/* ── SUCCESS / ERROR / LOADING ───────────────────── + Use the default toast text color instead of a + status tint (icon color already conveys status). */ /* spinner inherits accent color */ .codra-toast-loader svg { @@ -1033,26 +995,18 @@ /* ── WARNING ─────────────────────────────────────── */ .codra-toast-warning { - background: oklch(98.5% 0.04 65) !important; - border-color: oklch(82% 0.13 65) !important; - color: oklch(35% 0.12 65) !important; + color: oklch(35% 0.12 65) !important; } .dark .codra-toast-warning { - background: oklch(16% 0.08 65) !important; - border-color: oklch(35% 0.14 65) !important; - color: oklch(82% 0.14 65) !important; + color: oklch(82% 0.14 65) !important; } /* ── INFO ────────────────────────────────────────── */ .codra-toast-info { - background: oklch(98.5% 0.03 250) !important; - border-color: oklch(80% 0.12 250) !important; - color: oklch(30% 0.12 250) !important; + color: oklch(30% 0.12 250) !important; } .dark .codra-toast-info { - background: oklch(15% 0.07 250) !important; - border-color: oklch(33% 0.12 250) !important; - color: oklch(80% 0.12 250) !important; + color: oklch(80% 0.12 250) !important; } diff --git a/src/client/components/features/dashboard/updates-email-prompt.tsx b/src/client/components/features/dashboard/updates-email-prompt.tsx index 2f94167..f46042d 100644 --- a/src/client/components/features/dashboard/updates-email-prompt.tsx +++ b/src/client/components/features/dashboard/updates-email-prompt.tsx @@ -49,20 +49,21 @@ export function UpdatesEmailPrompt() { return (
-
+
- +

Get important Codra updates

-

- Add an email for release notes, security fixes, and upgrade heads-up. You can opt out from any update email later. No spam. +

+ Add an email for release notes, security fixes, and upgrade heads-up.{' '} + You can opt out from any update email later. No spam.

-
+ -
- -
+
diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx index c972f5e..b01ec74 100644 --- a/src/client/components/features/job-detail/file-finding.tsx +++ b/src/client/components/features/job-detail/file-finding.tsx @@ -15,7 +15,7 @@ interface FileFindingProps { export function FileFinding({ file }: FileFindingProps) { return ( -
+
@@ -23,7 +23,7 @@ export function FileFinding({ file }: FileFindingProps) {
- + {file.fileStatus === 'done' && } {file.parsedComments.length > 0 && ( {file.parsedComments.length} diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx index 084eb1f..a1b4b32 100644 --- a/src/client/components/features/job-detail/job-findings-list.tsx +++ b/src/client/components/features/job-detail/job-findings-list.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { FileText } from 'lucide-react'; -import { cn } from '@client/lib/utils'; import type { JobDetail } from '@shared/schema'; import { reviewSeverities } from '@shared/schema'; +import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs'; import { FileFinding } from './file-finding'; import { CommentCard } from './comment-card'; import { severityConfig } from './constants'; @@ -24,22 +24,16 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
View by -
- {(['files', 'severity'] as const).map((view) => ( - - ))} -
+ setViewBy(v as 'files' | 'severity')} variant="segment"> + + + Files + + + Severity + + +
@@ -59,6 +53,30 @@ export function JobFindingsList({ job }: JobFindingsListProps) { ) : (
+ {job.files.some((f) => f.fileStatus === 'failed') && ( +
+ {/* Group header */} +
+ + + Failed Files + + + {job.files.filter((f) => f.fileStatus === 'failed').length} + +
+ {/* Failed files list */} +
+ {job.files.filter((f) => f.fileStatus === 'failed').map((file) => ( + + ))} +
+
+ )} + {reviewSeverities.map((groupName) => { const comments = job.files.flatMap((f) => f.parsedComments diff --git a/src/client/components/features/job-detail/job-header.tsx b/src/client/components/features/job-detail/job-header.tsx index 787ff00..1b9afb2 100644 --- a/src/client/components/features/job-detail/job-header.tsx +++ b/src/client/components/features/job-detail/job-header.tsx @@ -1,16 +1,63 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; -import { ChevronRight, ExternalLink, RotateCcw, Terminal } from 'lucide-react'; +import { + ChevronRight, + ExternalLink, + Loader2, + RotateCcw, + Terminal, + Trash2, +} from 'lucide-react'; import { Button } from '@client/components/ui/button'; +import { ConfirmDialog } from '@client/components/ui/confirm-dialog'; import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; import type { JobDetail } from '@shared/schema'; +/* Stop icon: outlined circle with a solid square inside. Lucide's CircleStop strokes the inner + square too, which at 14px reads as a blob — filling it keeps the stop symbol legible. */ +function StopIcon({ size = 14 }: { size?: number }) { + return ( + + ); +} + interface JobHeaderProps { job: JobDetail; - isRetrying: boolean; - onRetry: () => void; + isRerunning: boolean; + isStopping: boolean; + isDeleting: boolean; + onRerun: () => void; + onStop: () => void; + onDelete: () => void; } -export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) { +export function JobHeader({ + job, + isRerunning, + isStopping, + isDeleting, + onRerun, + onStop, + onDelete, +}: JobHeaderProps) { + const [stopOpen, setStopOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const canStop = job.status === 'running' || job.status === 'queued'; + return ( <>
@@ -43,24 +90,71 @@ export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) {

-
- + + + + {/* A single re-run control. It always restarts the review from the beginning (a fresh + review of every file) and works whether the job is finished, failed, or still running. */} + +
+ + + + + ); 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 0bc5915..61e1101 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -3,6 +3,7 @@ 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, JobStep } from '@shared/schema'; +import { formatDuration } from '@client/lib/utils'; interface JobMetaCardsProps { job: JobDetail; @@ -13,8 +14,9 @@ function elapsedSec(step: JobStep): string | null { 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`; + // Reuse the shared formatter so long phases roll up into minutes/hours (e.g. "6m 24s") + // instead of an unwieldy "383.7s". + return formatDuration(end - start); } return null; } diff --git a/src/client/components/layout/app-shell.tsx b/src/client/components/layout/app-shell.tsx index f467b26..f26fa47 100644 --- a/src/client/components/layout/app-shell.tsx +++ b/src/client/components/layout/app-shell.tsx @@ -1,5 +1,6 @@ -import { NavLink, Outlet, Link } from 'react-router-dom'; -import { useEffect, useState, type CSSProperties } from 'react'; +import { NavLink, Outlet, Link, useMatch, useResolvedPath } from 'react-router-dom'; +import { useEffect, useState, type CSSProperties, type ComponentType } from 'react'; +import { SharedLayoutBg } from '@client/components/motion/shared-layout-bg'; import { api } from '@client/lib/api'; import { LayoutDashboard, @@ -36,6 +37,81 @@ const links = [ { to: '/settings', label: 'Settings', icon: Settings, end: false }, ]; +/** + * A sidebar nav item that resolves active state via hooks instead of + * NavLink's render-prop pattern, so SharedLayoutBg's cloneElement receives + * plain JSX children rather than a function. + */ +function SidebarNavItem({ + to, + end, + label, + icon: Icon, + onClick, +}: { + to: string; + end: boolean; + label: string; + icon: ComponentType<{ size?: number; strokeWidth?: number }>; + onClick: () => void; +}) { + const resolved = useResolvedPath(to); + const match = useMatch({ path: resolved.pathname, end }); + const isActive = match !== null; + + return ( + + {/* Active left bar */} + + + {/* Active shine — white beam reads on both white & black sidebar */} + {isActive && ( + + ); +} + const collapsedTooltipClass = [ 'lg:pointer-events-none lg:absolute lg:left-[calc(100%+1rem)]', 'lg:z-50 lg:w-max lg:max-w-44 lg:rounded-lg', @@ -47,6 +123,80 @@ const collapsedTooltipClass = [ 'lg:group-focus-visible:opacity-100 lg:group-focus-visible:translate-x-0', ]; +/** + * Collapsed (icon-only) sidebar nav item. + * Same fixed oklch color tokens as SidebarNavItem — no dark: variants. + */ +function CollapsedNavItem({ + to, + end, + label, + icon: Icon, + onClick, +}: { + to: string; + end: boolean; + label: string; + icon: ComponentType<{ size?: number; strokeWidth?: number }>; + onClick: () => void; +}) { + const resolved = useResolvedPath(to); + const match = useMatch({ path: resolved.pathname, end }); + const isActive = match !== null; + + return ( + + {/* Active left bar */} + + + {/* Active shine — lime beam */} + {isActive && ( + + ); +} + function getStoredSidebarCollapsed(): boolean { if (typeof window === 'undefined') return false; try { @@ -120,7 +270,7 @@ export function AppShell() { sidebarCollapsed && 'dashboard-sidebar-collapsed', 'fixed bottom-3 left-3 top-3 z-40 flex flex-col', 'rounded-xl border border-border/60', - 'bg-white dark:bg-black backdrop-blur-2xl', + 'bg-white dark:bg-black', 'text-black dark:text-white', 'shadow-[0_6px_20px_-8px_oklch(0%_0_0/0.14),0_0_0_1px_color-mix(in_oklch,var(--primary)_6%,transparent)]', 'dark:shadow-[0_8px_24px_-10px_oklch(0%_0_0/0.42),0_0_0_1px_color-mix(in_oklch,var(--primary)_9%,transparent)]', @@ -218,79 +368,49 @@ export function AppShell() {

)} -
- {links.map(({ to, label, end, icon: Icon }) => ( - setMobileMenuOpen(false)} - className={({ isActive }) => cn( - 'dashboard-sidebar-action', - 'group relative flex h-[2.375rem] items-center gap-3 rounded-lg text-sm font-medium', - 'outline-none transition-[background-color,color,box-shadow,transform] duration-200 ease-[var(--ease-out-quart)]', - 'hover:-translate-y-px active:translate-y-0', - 'focus-visible:ring-2 focus-visible:ring-ring', - sidebarCollapsed ? 'lg:w-[2.375rem] lg:justify-center lg:px-0' : 'px-3', - isActive - ? [ - 'text-black dark:text-white', - 'bg-[color-mix(in_oklch,var(--primary)_12%,transparent)]', - 'dark:bg-[color-mix(in_oklch,var(--primary)_18%,transparent)]', - ] - : 'text-black hover:bg-secondary/70 hover:text-black hover:shadow-[0_8px_18px_-14px_color-mix(in_oklch,var(--foreground)_35%,transparent)] dark:text-white dark:hover:text-white', - )} - > - {({ isActive }) => ( - <> - {/* Active left bar */} - - - {isActive && ( - - ))} -
+ {/* Collapsed: plain icon buttons stacked */} + {sidebarCollapsed ? ( + + {links.map(({ to, label, end, icon }) => ( +
+ setMobileMenuOpen(false)} + /> +
+ ))} +
+ ) : ( + /* Expanded: SharedLayoutBg provides the animated hover pill. + Uses SidebarNavItem (hook-based active state) instead of NavLink + render props — cloneElement can't wrap a render function as children. */ + + {links.map(({ to, label, end, icon }) => ( + /* Plain div is the direct child SharedLayoutBg clones — it injects + the pill + z-10 wrapper into a real DOM element. SidebarNavItem + (a custom component) ignores injected children so can't be the + direct child. */ +
+ setMobileMenuOpen(false)} + /> +
+ ))} +
+ )} {/* Divider */} diff --git a/src/client/components/layout/page-header.tsx b/src/client/components/layout/page-header.tsx index 3590e19..dbe1ac8 100644 --- a/src/client/components/layout/page-header.tsx +++ b/src/client/components/layout/page-header.tsx @@ -3,7 +3,7 @@ import { cn } from '@client/lib/utils'; import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; interface PageHeaderProps extends React.HTMLAttributes { - category: string; + category?: string; title: string; description?: React.ReactNode; actions?: React.ReactNode; @@ -21,16 +21,13 @@ export function PageHeader({ return ( <>
-

- {category} -

{title} {props.versionBadge && ( @@ -40,13 +37,13 @@ export function PageHeader({ )}

{description && ( -
+
{description}
)}
{actions && ( -
+
{actions}
)} @@ -55,3 +52,4 @@ export function PageHeader({ ); } + diff --git a/src/client/components/motion/shared-layout-bg.tsx b/src/client/components/motion/shared-layout-bg.tsx new file mode 100644 index 0000000..f1ed557 --- /dev/null +++ b/src/client/components/motion/shared-layout-bg.tsx @@ -0,0 +1,116 @@ +// beui.dev/components/motion/shared-layout-bg +import { + AnimatePresence, + motion, + useReducedMotion, + type Variants, +} from "motion/react"; +import { + Children, + cloneElement, + isValidElement, + useId, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { cn } from "@client/lib/utils"; + +const SPRING_LAYOUT = { + type: "spring" as const, + stiffness: 400, + damping: 40, + mass: 0.6, +}; + +export interface SharedLayoutBgProps { + children: ReactNode; + className?: string; + /** Tailwind class applied to the moving pill. */ + pillClassName?: string; + /** Horizontal inset of the pill relative to each row (px). Default 8. */ + inset?: number; +} + +const variants: Variants = { + initial: { opacity: 0, filter: "blur(6px)" }, + animate: { opacity: 1, filter: "blur(0px)" }, + exit: (isActive: boolean) => + !isActive ? { opacity: 0, filter: "blur(6px)" } : {}, +}; + +const reducedVariants: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}), +}; + +export function SharedLayoutBg({ + children, + className, + pillClassName, + inset = 0, +}: SharedLayoutBgProps) { + const [activeId, setActiveId] = useState(null); + const uid = useId(); + const reduce = useReducedMotion(); + + return ( + setActiveId(null)} + className={cn("flex w-full flex-col", className)} + > + {Children.toArray(children) + .filter(isValidElement) + .map((child, index) => { + const el = child as ReactElement<{ + className?: string; + onMouseEnter?: (e?: any) => void; + children?: ReactNode; + }>; + const childKey = el.key ? String(el.key) : `item-${index}`; + return cloneElement( + el, + { + key: childKey, + className: cn("relative z-10", el.props.className), + onMouseEnter: (e: any) => { + el.props.onMouseEnter?.(e); + setActiveId(childKey); + }, + }, + <> +
+ + {activeId !== null ? ( + + {activeId === childKey ? ( + + ) : null} + + ) : null} + +
+ {el} + + ); + })} +
+ ); +} diff --git a/src/client/components/motion/smooth-scroll.tsx b/src/client/components/motion/smooth-scroll.tsx new file mode 100644 index 0000000..53aa6fa --- /dev/null +++ b/src/client/components/motion/smooth-scroll.tsx @@ -0,0 +1,269 @@ +// beui.dev/components/motion/scroll-animation +import type Lenis from 'lenis'; +import { ReactLenis, useLenis } from 'lenis/react'; +import { type MotionValue, useMotionValue, useReducedMotion } from 'motion/react'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; + +// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a +// named local fn (not a lib/ease token) because tokens are bezier control +// points for the motion lib, while Lenis needs a (t) => number easing fn. +const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t)); + +export type ScrollTarget = number | string | HTMLElement; + +export type ScrollToOptions = { + offset?: number; + immediate?: boolean; + duration?: number; +}; + +export type SmoothScrollApi = { + /** Underlying Lenis instance, or null on the reduced-motion / native path. */ + lenis: Lenis | null; + /** Current scroll offset in px. */ + scrollY: MotionValue; + /** Scroll position as 0..1 of the scrollable height. */ + progress: MotionValue; + /** Signed scroll velocity (px/frame); drives velocity-based effects. */ + velocity: MotionValue; + /** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */ + scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void; +}; + +const SmoothScrollContext = createContext(null); + +export interface SmoothScrollProps { + children: ReactNode; + /** Drive the page (window) when true, or a contained scroll area when false. */ + root?: boolean; + /** Smoothing factor; lower is smoother and heavier. */ + lerp?: number; + /** Wheel / programmatic ease duration in seconds. */ + duration?: number; + orientation?: 'vertical' | 'horizontal'; + /** Wheel scroll speed multiplier. */ + wheelMultiplier?: number; + /** Smooth touch scrolling. Off by default — native momentum is good on mobile. */ + touch?: boolean; + className?: string; +} + +type ScrollSource = Window | HTMLElement; + +function readMetrics(target: ScrollSource) { + if (target instanceof Window) { + const max = Math.max( + 0, + document.documentElement.scrollHeight - window.innerHeight, + ); + return { y: window.scrollY, max }; + } + return { + y: target.scrollTop, + max: Math.max(0, target.scrollHeight - target.clientHeight), + }; +} + +function resolveTop( + target: ScrollTarget, + source: ScrollSource, + offset = 0, +): number { + if (typeof target === 'number') return target + offset; + if (source instanceof Window) { + const el = + typeof target === 'string' ? document.querySelector(target) : target; + if (!el) return window.scrollY; + return el.getBoundingClientRect().top + window.scrollY + offset; + } + const el = + typeof target === 'string' ? source.querySelector(target) : target; + if (!(el instanceof HTMLElement)) return source.scrollTop; + return el.offsetTop + offset; +} + +/** Pushes Lenis' live scroll state into the shared motion values. */ +function LenisBridge({ + scrollY, + progress, + velocity, + lenisRef, +}: { + scrollY: MotionValue; + progress: MotionValue; + velocity: MotionValue; + lenisRef: { current: Lenis | null }; +}) { + const lenis = useLenis((instance) => { + scrollY.set(instance.scroll); + progress.set(instance.progress); + velocity.set(instance.velocity); + }); + useEffect(() => { + lenisRef.current = lenis ?? null; + return () => { + lenisRef.current = null; + }; + }, [lenis, lenisRef]); + return null; +} + +/** Native scroll listener for the reduced-motion path and the no-provider fallback. */ +function useNativeScrollSync( + enabled: boolean, + getTarget: () => ScrollSource | null, + scrollY: MotionValue, + progress: MotionValue, + velocity: MotionValue, +) { + useEffect(() => { + if (!enabled) return; + const target = getTarget(); + if (!target) return; + let lastY = readMetrics(target).y; + let lastT = performance.now(); + const onScroll = () => { + const { y, max } = readMetrics(target); + const now = performance.now(); + const dt = now - lastT || 16; + scrollY.set(y); + progress.set(max > 0 ? y / max : 0); + velocity.set(((y - lastY) / dt) * 16); + lastY = y; + lastT = now; + }; + onScroll(); + target.addEventListener('scroll', onScroll, { passive: true }); + return () => target.removeEventListener('scroll', onScroll); + }, [enabled, getTarget, scrollY, progress, velocity]); +} + +export function SmoothScroll({ + children, + root = true, + lerp = 0.1, + duration = 1.2, + orientation = 'vertical', + wheelMultiplier = 1, + touch = false, + className, +}: SmoothScrollProps) { + const reduce = useReducedMotion(); + const scrollY = useMotionValue(0); + const progress = useMotionValue(0); + const velocity = useMotionValue(0); + const lenisRef = useRef(null); + const containerRef = useRef(null); + + const nativeSource = useCallback( + (): ScrollSource | null => (root ? window : containerRef.current), + [root], + ); + + const scrollTo = useCallback( + (target: ScrollTarget, options?: ScrollToOptions) => { + const lenis = lenisRef.current; + if (lenis && !reduce) { + lenis.scrollTo(target, { + offset: options?.offset, + duration: options?.duration, + immediate: options?.immediate, + }); + return; + } + const source = nativeSource(); + const behavior = reduce || options?.immediate ? 'auto' : 'smooth'; + const top = resolveTop(target, source ?? window, options?.offset); + (source ?? window).scrollTo({ top, behavior }); + }, + [reduce, nativeSource], + ); + + // Reduced motion drives the native listener; the Lenis path leaves it + // disabled and lets LenisBridge feed the values instead. + useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity); + + const api = useMemo( + () => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }), + [scrollY, progress, velocity, scrollTo], + ); + + if (reduce) { + return ( + +
+ {children} +
+
+ ); + } + + return ( + + + + {children} + + + ); +} + +/** + * Read the page's smooth-scroll state. Inside it returns the + * shared motion values; outside it falls back to a native window scroll + * listener so scroll-driven components still work without the provider. + */ +export function useSmoothScroll(): SmoothScrollApi { + const ctx = useContext(SmoothScrollContext); + const scrollY = useMotionValue(0); + const progress = useMotionValue(0); + const velocity = useMotionValue(0); + + const windowSource = useCallback((): ScrollSource => window, []); + useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity); + + const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => { + window.scrollTo({ + top: resolveTop(target, window, options?.offset), + behavior: options?.immediate ? 'auto' : 'smooth', + }); + }, []); + + const fallback = useMemo( + () => ({ lenis: null, scrollY, progress, velocity, scrollTo }), + [scrollY, progress, velocity, scrollTo], + ); + + return ctx ?? fallback; +} diff --git a/src/client/components/motion/stepped-slider.tsx b/src/client/components/motion/stepped-slider.tsx new file mode 100644 index 0000000..beb46ab --- /dev/null +++ b/src/client/components/motion/stepped-slider.tsx @@ -0,0 +1,321 @@ +// Discrete-step slider with a spring-smoothed thumb and a live value readout +// that sits statically above the track (no floating/portal-positioned +// tooltip involved). Adapted from a min/max/step numeric range slider so +// labeled steps (e.g. Low/Medium/High/Max) line up exactly on evenly spaced +// stops. +import { + motion, + useMotionTemplate, + useMotionValue, + useReducedMotion, + useSpring, + useTransform, +} from 'motion/react'; +import { + type KeyboardEvent, + type PointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { cn } from '@client/lib/utils'; +import { useIsDarkMode } from '@client/hooks/use-is-dark-mode'; + +const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const; +const SPRING_BOUNCY = { type: 'spring', stiffness: 500, damping: 14, mass: 0.7 } as const; + +// Deterministic 0..1 seed derived from the slider's id, so two sliders maxed +// out at the same time get slightly different hues and animation timing +// instead of pulsing in perfect, obviously-copy-pasted unison. +function seedFromId(id: string | undefined) { + if (!id) return 0; + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + hash = (hash * 31 + id.charCodeAt(i)) >>> 0; + } + return (hash % 997) / 997; +} + +export interface SteppedSliderStep { + value: number; + label: string; +} + +export interface SteppedSliderProps { + id?: string; + value?: number; + defaultValue?: number; + onValueChange?: (value: number) => void; + min?: number; + max?: number; + step?: number; + /** Tick dots + labels rendered at each defined step. */ + steps?: SteppedSliderStep[]; + /** Live value readout shown above the track, and used as the accessible aria-valuetext. Defaults to the raw value. */ + formatValue?: (value: number) => string; + disabled?: boolean; + className?: string; + 'aria-label'?: string; + 'aria-labelledby'?: string; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +export function SteppedSlider({ + id, + value, + defaultValue = 0, + onValueChange, + min = 0, + max = 100, + step = 1, + steps = [], + formatValue, + disabled = false, + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, +}: SteppedSliderProps) { + const reduce = useReducedMotion(); + const isDark = useIsDarkMode(); + const seed = useMemo(() => seedFromId(id), [id]); + const trackRef = useRef(null); + const [internal, setInternal] = useState(defaultValue); + const [active, setActive] = useState(false); + // Live position while dragging, decoupled from the committed value so that + // onValueChange fires exactly once per gesture (on release) instead of on + // every pointer-move tick — callers may react to a commit by opening a + // confirmation dialog, which must not re-fire while the pointer is still down. + const [dragValue, setDragValue] = useState(null); + const controlled = value !== undefined; + const committedValue = clamp(controlled ? (value as number) : internal, min, max); + const current = active && dragValue !== null ? clamp(dragValue, min, max) : committedValue; + const percent = ((current - min) / (max - min)) * 100; + const isMaxed = current === max; + + // Teal accent for the "maxed out" state, hue-shifted slightly per instance + // and timed with a per-instance offset so simultaneous max sliders read as + // one cohesive design rather than a mirrored duplicate. + const hue = 185 + (seed - 0.5) * 16; + const glowColor = isDark ? `oklch(68% 0.14 ${hue})` : `oklch(52% 0.13 ${hue})`; + const fillGradient = isDark + ? `linear-gradient(90deg, oklch(28% 0.08 ${hue}), oklch(62% 0.12 ${hue}))` + : `linear-gradient(90deg, oklch(93% 0.03 ${hue}), oklch(56% 0.13 ${hue}))`; + const dotDriftDuration = 1.4 + seed * 0.5; + const glowDuration = 1.6 + seed * 0.6; + + const target = useMotionValue(percent); + useEffect(() => { + target.set(percent); + }, [percent, target]); + const smooth = useSpring(target, SPRING_GLIDE); + const pos = reduce ? target : smooth; + const left = useMotionTemplate`${pos}%`; + const thumbX = useTransform(pos, (p) => `${-p}%`); + + const snapValue = useCallback( + (next: number) => clamp(Math.round((next - min) / step) * step + min, min, max), + [min, max, step], + ); + + const commit = useCallback( + (next: number) => { + const snapped = snapValue(next); + if (!controlled) setInternal(snapped); + onValueChange?.(snapped); + }, + [controlled, onValueChange, snapValue], + ); + + const valueFromX = useCallback( + (clientX: number) => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect) return committedValue; + const ratio = clamp((clientX - rect.left) / rect.width, 0, 1); + return min + ratio * (max - min); + }, + [committedValue, min, max], + ); + + const onPointerDown = useCallback( + (event: PointerEvent) => { + if (disabled) return; + event.currentTarget.setPointerCapture(event.pointerId); + setActive(true); + setDragValue(snapValue(valueFromX(event.clientX))); + }, + [disabled, valueFromX, snapValue], + ); + + const onPointerMove = useCallback( + (event: PointerEvent) => { + if (!active || disabled) return; + setDragValue(snapValue(valueFromX(event.clientX))); + }, + [active, disabled, valueFromX, snapValue], + ); + + const endDrag = useCallback( + (event: PointerEvent) => { + event.currentTarget.releasePointerCapture?.(event.pointerId); + setActive(false); + setDragValue((pending) => { + if (pending !== null) commit(pending); + return null; + }); + }, + [commit], + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (disabled) return; + const map: Record = { + ArrowRight: committedValue + step, + ArrowUp: committedValue + step, + ArrowLeft: committedValue - step, + ArrowDown: committedValue - step, + Home: min, + End: max, + }; + if (event.key in map) { + event.preventDefault(); + commit(map[event.key]); + } + }, + [disabled, committedValue, step, min, max, commit], + ); + + const valueLabel = formatValue ? formatValue(current) : String(current); + + return ( +
+ {/* live value readout — sits in normal flow above the track, always visible */} +
+ {valueLabel} +
+ +
+ {/* fill — runs from the left edge to the thumb; static teal gradient with a dot texture + steadily drifting left-to-right at the top step. Every dot has the same fixed + brightness — only position moves — so it reads as one smooth, consistent motion + instead of some dots being brighter than others. The pattern repeats every 10px and + shifts by exactly one period, so the loop point is never visible. */} + + {isMaxed ? ( +
+ +
+ ) : ( +
+ )} + + + {/* ticks — slight inset so the end dots don't clip; sized/ringed up at the top step so they + read as distinct markers instead of blending into the dot texture behind them */} +
+ {steps.map((tick) => { + const tp = ((tick.value - min) / (max - min)) * 100; + return ( + + ); + })} +
+ + {/* pulsing glow halo behind the thumb at the top step — kept as its own layer so only + opacity (a plain number) is animated, since Motion's box-shadow interpolator can't + parse CSS custom properties inside the color stops */} + {isMaxed && !reduce && ( + + )} + + {/* vertical bar thumb — contained at both ends via thumbX */} + +
+ + {steps.length > 0 && ( +
+
+ {steps.map((tick, index) => { + const isFirst = index === 0; + const isLast = index === steps.length - 1; + const tp = ((tick.value - min) / (max - min)) * 100; + return ( + + {tick.label} + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/client/components/motion/tabs.tsx b/src/client/components/motion/tabs.tsx new file mode 100644 index 0000000..038883e --- /dev/null +++ b/src/client/components/motion/tabs.tsx @@ -0,0 +1,189 @@ +// beui.dev/components/motion/tabs +import { motion, MotionConfig, useReducedMotion, type Transition } from 'motion/react'; +import { createContext, useContext, useId, useState, type ReactNode } from 'react'; +import { EASE_OUT } from '@client/lib/ease'; +import { cn } from '@client/lib/utils'; + +type Variant = 'pill' | 'underline' | 'segment'; + +type Ctx = { + value: string; + setValue: (v: string) => void; + layoutId: string; + variant: Variant; +}; + +const TabsCtx = createContext(null); + +function useTabs() { + const ctx = useContext(TabsCtx); + if (!ctx) throw new Error('Tabs.* must be used inside '); + return ctx; +} + +// Weighty spring for the active-tab indicator: a touch of overshoot so it +// settles with life instead of snapping. +const transition: Transition = { + type: 'spring', + stiffness: 170, + damping: 24, + mass: 1.2, +}; + +export function Tabs({ + defaultValue, + value, + onValueChange, + variant = 'pill', + children, + className, +}: { + defaultValue?: string; + value?: string; + onValueChange?: (v: string) => void; + variant?: Variant; + children: ReactNode; + className?: string; +}) { + const [internal, setInternal] = useState(defaultValue ?? ''); + const layoutId = useId(); + const reduce = useReducedMotion(); + const controlled = value !== undefined; + const current = controlled ? value : internal; + const setValue = (v: string) => { + if (!controlled) setInternal(v); + onValueChange?.(v); + }; + return ( + + + {/* layoutRoot: the indicator's layoutId measures in page coordinates, so + inside fixed/scrolled containers it would replay scroll offsets as + movement. The pill only ever travels within the list, so scoping + projection to the Tabs wrapper is always correct. */} + + {children} + + + + ); +} + +const listClasses: Record = { + pill: 'inline-flex items-center gap-1 rounded-full bg-card p-1', + underline: 'inline-flex items-center gap-1 border-b border-border', + segment: 'inline-flex items-center gap-0 rounded-lg bg-card p-0.5', +}; + +export function TabsList({ children, className }: { children: ReactNode; className?: string }) { + const { variant } = useTabs(); + return ( +
+ {children} +
+ ); +} + +export function TabsTrigger({ + value, + children, + className, + indicatorClassName, +}: { + value: string; + children: ReactNode; + className?: string; + indicatorClassName?: string; +}) { + const { value: current, setValue, layoutId, variant } = useTabs(); + const active = current === value; + + if (variant === 'underline') { + return ( + + ); + } + + // Pill + Segment use the same trick: a max-contrast pill slides via layoutId, + // text uses `mix-blend-exclusion` so it inverts dynamically against the moving bg. + const radius = variant === 'pill' ? 'rounded-full' : 'rounded-md'; + + return ( +
+ {active ? ( + + ) : null} + +
+ ); +} + +export function TabsContent({ + value, + children, + className, +}: { + value: string; + children: ReactNode; + className?: string; +}) { + const { value: current } = useTabs(); + const reduce = useReducedMotion(); + const active = current === value; + // Inactive panels stay mounted but hidden, so their content (e.g. source + // code) is present in the server-rendered HTML for crawlers and assistive + // tech, instead of being dropped from the DOM. + if (!active) { + return ( + + ); + } + return ( + + {children} + + ); +} diff --git a/src/client/components/shared/empty-state.tsx b/src/client/components/shared/empty-state.tsx index 1106146..f9f306d 100644 --- a/src/client/components/shared/empty-state.tsx +++ b/src/client/components/shared/empty-state.tsx @@ -1,11 +1,20 @@ import React from 'react'; -import { Button } from '@client/components/ui/button'; import { cn } from '@client/lib/utils'; +import { Button } from '@client/components/ui/button'; interface EmptyStateProps { icon?: React.ReactNode; title: string; description?: string; + /** Bullet-point hints shown with green dot prefix (Beetle-style) */ + hints?: string[]; + /** Renders an outlined link button below the hints */ + linkAction?: { + label: string; + href?: string; + onClick?: () => void; + }; + /** Legacy single action button */ action?: { label: string; onClick: () => void; @@ -13,27 +22,70 @@ interface EmptyStateProps { className?: string; } -export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { +export function EmptyState({ icon, title, description, hints, linkAction, action, className }: EmptyStateProps) { return (
{icon && ( -
+
{icon}
)}
-

{title}

+

{title}

{description && (

{description}

)}
+ + {hints && hints.length > 0 && ( +
    + {hints.map((hint, i) => ( +
  • + + {hint} +
  • + ))} +
+ )} + + {linkAction && ( +
+ {linkAction.href ? ( + + ) : ( + + )} +
+ )} + {action && ( - )} diff --git a/src/client/components/shared/github-mark.tsx b/src/client/components/shared/github-mark.tsx new file mode 100644 index 0000000..376cb4a --- /dev/null +++ b/src/client/components/shared/github-mark.tsx @@ -0,0 +1,15 @@ +/** GitHub brand mark — lucide-react no longer ships brand icons. */ +export function GithubMark({ size = 16, className }: { size?: number; className?: string }) { + return ( + + ); +} diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx index a078763..08b0cb2 100644 --- a/src/client/components/shared/jobs-table.tsx +++ b/src/client/components/shared/jobs-table.tsx @@ -29,8 +29,8 @@ interface JobsTableProps { } const DEFAULT_COLUMNS: Column[] = [ - 'repo', 'pr', + 'repo', 'status', 'verdict', 'created', @@ -38,7 +38,7 @@ const DEFAULT_COLUMNS: Column[] = [ ]; const thCls = - 'px-4 py-3 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-muted-foreground select-none'; + 'px-4 py-3 text-left text-xs font-semibold text-muted-foreground/70 select-none'; const COLUMN_CLASSES: Record = { repo: 'w-[190px] max-w-[190px]', @@ -53,12 +53,12 @@ const COLUMN_CLASSES: Record = { const COLUMN_HEADERS: Record = { repo: 'Repository', - pr: 'Pull request', + pr: 'Title', status: 'Status', verdict: 'Verdict', files: 'Files', tokens: 'Tokens', - created: 'Created', + created: 'Last Updated', action: '', }; @@ -228,7 +228,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
- + {cols.map((col) => (
void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + confirmVariant?: ButtonProps['variant']; + onConfirm: () => void; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Continue', + cancelLabel = 'Cancel', + confirmVariant = 'default', + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + +
+ + + +
+ {title} + + {description} + +
+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/src/client/components/ui/dropdown-menu.tsx b/src/client/components/ui/dropdown-menu.tsx index 32c61fc..f56b6b1 100644 --- a/src/client/components/ui/dropdown-menu.tsx +++ b/src/client/components/ui/dropdown-menu.tsx @@ -1,209 +1,299 @@ -import * as React from 'react'; -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; +import { Slot } from '@radix-ui/react-slot'; +import { AnimatePresence, motion, useReducedMotion, type Transition } from 'motion/react'; +import { + createContext, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent, + type ReactNode, + type RefCallback, +} from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@client/lib/utils'; -const DropdownMenu = DropdownMenuPrimitive.Root; - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const menuContentClass = - 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white p-1 text-zinc-900 shadow-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-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900 data-[highlighted]:bg-zinc-200 data-[highlighted]:text-zinc-900 dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground dark:data-[highlighted]:bg-primary/[0.12] dark:data-[highlighted]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50'; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - void; + triggerRef: React.RefObject; + panelRef: React.RefObject; + contentId: string; +} + +const DropdownMenuContext = createContext(null); + +function useMenuCtx(component: string) { + const ctx = useContext(DropdownMenuContext); + if (!ctx) throw new Error(`${component} must be used within `); + return ctx; +} + +export function DropdownMenu({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const contentId = `${useId()}-menu`; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); + const onPointer = (e: PointerEvent) => { + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + setOpen(false); + }; + window.addEventListener('keydown', onKey); + window.addEventListener('pointerdown', onPointer); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('pointerdown', onPointer); + }; + }, [open]); + + return ( + + {children} + + ); +} + +export function DropdownMenuTrigger({ + asChild, + children, +}: { + asChild?: boolean; + children: ReactNode; +}) { + const ctx = useMenuCtx('DropdownMenuTrigger'); + const Comp = asChild ? Slot : 'button'; + const setRef: RefCallback = (node) => { + ctx.triggerRef.current = node; + }; + + return ( + ctx.setOpen(!ctx.open)} + > + {children} + + ); +} + +export interface DropdownMenuContentProps { + side?: Side; + align?: Align; + sideOffset?: number; + alignOffset?: number; + className?: string; + children: ReactNode; +} + +const VIEWPORT_PADDING = 8; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +export function DropdownMenuContent({ + side = 'bottom', + align = 'start', + sideOffset = 4, + alignOffset = 0, + className, + children, +}: DropdownMenuContentProps) { + const ctx = useMenuCtx('DropdownMenuContent'); + const reduce = useReducedMotion() ?? false; + const [style, setStyle] = useState({ position: 'fixed', visibility: 'hidden' }); + + const setRefs: RefCallback = (node) => { + ctx.panelRef.current = node; + }; + + const getItems = () => + Array.from( + ctx.panelRef.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"]):not([disabled])') ?? [], + ); + + // Move focus into the menu on open and restore it to the trigger on close, so + // keyboard users land on the first item and return to where they were. + const wasOpen = useRef(false); + useEffect(() => { + if (ctx.open) { + wasOpen.current = true; + const id = requestAnimationFrame(() => getItems()[0]?.focus()); + return () => cancelAnimationFrame(id); + } + if (wasOpen.current) { + wasOpen.current = false; + // Only pull focus back to the trigger if it isn't already somewhere + // deliberate (e.g. an outside click that landed on another control). + const active = document.activeElement; + if (!active || active === document.body || ctx.panelRef.current?.contains(active)) { + ctx.triggerRef.current?.focus(); + } + } + }, [ctx.open]); + + useLayoutEffect(() => { + if (!ctx.open) return; + const trigger = ctx.triggerRef.current; + if (!trigger) return; + const update = () => { + const r = trigger.getBoundingClientRect(); + const panel = ctx.panelRef.current; + const menuW = panel?.offsetWidth ?? 0; + const menuH = panel?.offsetHeight ?? 0; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let top: number; + let left: number; + + if (side === 'top' || side === 'bottom') { + top = side === 'bottom' ? r.bottom + sideOffset : r.top - sideOffset - menuH; + left = align === 'start' ? r.left + alignOffset : r.right - menuW - alignOffset; + // Flip to the other side if the preferred one overflows and the opposite fits. + if (side === 'bottom' && top + menuH > vh - VIEWPORT_PADDING && r.top - sideOffset - menuH >= VIEWPORT_PADDING) { + top = r.top - sideOffset - menuH; + } else if (side === 'top' && top < VIEWPORT_PADDING && r.bottom + sideOffset + menuH <= vh - VIEWPORT_PADDING) { + top = r.bottom + sideOffset; + } + } else { + left = side === 'right' ? r.right + sideOffset : r.left - sideOffset - menuW; + top = align === 'start' ? r.top + alignOffset : r.bottom - menuH - alignOffset; + if (side === 'right' && left + menuW > vw - VIEWPORT_PADDING && r.left - sideOffset - menuW >= VIEWPORT_PADDING) { + left = r.left - sideOffset - menuW; + } else if (side === 'left' && left < VIEWPORT_PADDING && r.right + sideOffset + menuW <= vw - VIEWPORT_PADDING) { + left = r.right + sideOffset; + } + } + + // Keep the panel within the viewport regardless of side/align. + top = clamp(top, VIEWPORT_PADDING, Math.max(VIEWPORT_PADDING, vh - menuH - VIEWPORT_PADDING)); + left = clamp(left, VIEWPORT_PADDING, Math.max(VIEWPORT_PADDING, vw - menuW - VIEWPORT_PADDING)); + + setStyle({ position: 'fixed', top, left, visibility: 'visible' }); + }; + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [ctx.open, ctx.triggerRef, side, align, sideOffset, alignOffset]); + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + ctx.setOpen(false); + return; + } + const items = getItems(); + if (items.length === 0) return; + const current = items.indexOf(document.activeElement as HTMLElement); + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[(current + 1 + items.length) % items.length].focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + items[(current - 1 + items.length) % items.length].focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + items[0].focus(); + } else if (e.key === 'End') { + e.preventDefault(); + items[items.length - 1].focus(); + } + }; + + const originX = side === 'top' || side === 'bottom' ? (align === 'start' ? 'left' : 'right') : side === 'right' ? 'left' : 'right'; + const originY = side === 'bottom' ? 'top' : side === 'top' ? 'bottom' : align === 'start' ? 'top' : 'bottom'; + const slideDistance = 6; + const hiddenOffset = + side === 'bottom' + ? { y: -slideDistance } + : side === 'top' + ? { y: slideDistance } + : side === 'right' + ? { x: -slideDistance } + : { x: slideDistance }; + + const transition: Transition = reduce + ? { duration: 0.1 } + : { type: 'spring', duration: 0.35, bounce: 0.15 }; + + return createPortal( + // AnimatePresence unmounts the menu after its exit animation, so the closed + // menu leaves no invisible, focusable items in the tab order. + + {ctx.open && ( + + {children} + + )} + , + document.body, + ); +} + +export interface DropdownMenuItemProps { + asChild?: boolean; + className?: string; + onClick?: (e: MouseEvent) => void; + children: ReactNode; +} + +export function DropdownMenuItem({ asChild, className, onClick, children }: DropdownMenuItemProps) { + const ctx = useMenuCtx('DropdownMenuItem'); + const Comp = asChild ? Slot : 'button'; + + return ( + ) => { + onClick?.(e); + ctx.setOpen(false); + }} className={cn( - menuContentClass, - 'data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 ease-out', + 'relative flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors', + 'hover:bg-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900', + 'dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground', className, )} - {...props} - /> - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - + > + {children} + ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; +} + +export function DropdownMenuSeparator({ className }: { className?: string }) { + return
; +} diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx index 0f419aa..e6a9173 100644 --- a/src/client/components/ui/select.tsx +++ b/src/client/components/ui/select.tsx @@ -1,13 +1,33 @@ -import { ChevronDown } from 'lucide-react'; -import type { CSSProperties, ReactNode } from 'react'; -import { cn } from '@client/lib/utils'; +import { Check, ChevronDown } from 'lucide-react'; +import { motion, type Transition, useReducedMotion, type Variants } from 'motion/react'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from './dropdown-menu'; -import { Button } from './button'; + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { cn } from '@client/lib/utils'; +import { EASE_OUT } from '@client/lib/ease'; + +// Spring with bounce powers the unfold/separation; per-property timings in the +// content choreograph it. See the `animate`/`transition` props on the panel below. +const CHEVRON_TRANSITION: Transition = { type: 'spring', duration: 0.4, bounce: 0.3 }; + +const LIST_VARIANTS: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } }, +}; +const ITEM_VARIANTS: Variants = { + hidden: { opacity: 0, y: -6, filter: 'blur(3px)' }, + show: { opacity: 1, y: 0, filter: 'blur(0px)' }, +}; + +type Placement = 'bottom' | 'top'; interface SelectOption { value: string; @@ -26,9 +46,9 @@ interface SelectProps { leadingIcon?: ReactNode; /** * 'page' — trigger sits on the gray page background (e.g. "Last 30 days"). - * Dropdown gets white bg so it lifts off the page. - * 'card' — trigger sits inside a white card (e.g. "All statuses"). - * Dropdown gets zinc-50 bg so it's distinguishable from the card. + * Dropdown gets card bg so it lifts off the page. + * 'card' — trigger sits inside a card. + * Dropdown gets muted bg so it's distinguishable from the card. * Defaults to 'page'. */ variant?: 'page' | 'card'; @@ -46,8 +66,161 @@ export function Select({ leadingIcon, variant = 'page', }: SelectProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const triggerId = `${baseId}-trigger`; + const listId = `${baseId}-list`; + const triggerRef = useRef(null); + const panelRef = useRef(null); + const innerRef = useRef(null); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState('bottom'); + const [height, setHeight] = useState(0); + const [rect, setRect] = useState<{ left: number; width: number; top: number; bottom: number } | null>(null); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const optionRefs = useRef>([]); + const selectedOption = options.find((opt) => opt.value === value); + // Move the active-descendant highlight onto the current selection (or the first option) + // each time the listbox opens, so arrow-key navigation always starts from a sane position. + useEffect(() => { + if (!open) return; + const idx = options.findIndex((opt) => opt.value === value); + setHighlightedIndex(idx >= 0 ? idx : 0); + }, [open, options, value]); + + useEffect(() => { + if (!open) return; + optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' }); + }, [open, highlightedIndex]); + + // close on outside pointer / escape + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); + const onPointer = (e: PointerEvent) => { + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + setOpen(false); + }; + window.addEventListener('keydown', onKey); + window.addEventListener('pointerdown', onPointer); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('pointerdown', onPointer); + }; + }, [open]); + + useLayoutEffect(() => { + const node = innerRef.current; + if (!node) return; + const measure = () => setHeight(node.offsetHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + // Track the trigger's viewport position so the portaled panel can follow it, + // and flip upward when there isn't room below and there's more above. Scroll/resize + // fire far more often than the display repaints, so batch updates to at most once per + // animation frame instead of re-rendering on every raw event. + useLayoutEffect(() => { + if (!open) return; + const trigger = triggerRef.current; + if (!trigger) return; + let frame: number | null = null; + const update = () => { + frame = null; + const r = trigger.getBoundingClientRect(); + setRect({ left: r.left, width: r.width, top: r.top, bottom: r.bottom }); + const h = innerRef.current?.offsetHeight ?? 0; + const below = window.innerHeight - r.bottom; + const above = r.top; + setPlacement(below < h + 16 && above > below ? 'top' : 'bottom'); + }; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = requestAnimationFrame(update); + }; + update(); + window.addEventListener('scroll', scheduleUpdate, true); + window.addEventListener('resize', scheduleUpdate); + return () => { + if (frame !== null) cancelAnimationFrame(frame); + window.removeEventListener('scroll', scheduleUpdate, true); + window.removeEventListener('resize', scheduleUpdate); + }; + }, [open]); + + const moveHighlight = (next: number) => { + setHighlightedIndex(Math.min(Math.max(next, 0), options.length - 1)); + }; + + const onTriggerKeyDown = (e: ReactKeyboardEvent) => { + if (!open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setOpen(true); + } + return; + } + if (options.length === 0) return; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + moveHighlight(highlightedIndex + 1); + break; + case 'ArrowUp': + e.preventDefault(); + moveHighlight(highlightedIndex - 1); + break; + case 'Home': + e.preventDefault(); + moveHighlight(0); + break; + case 'End': + e.preventDefault(); + moveHighlight(options.length - 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + onValueChange(options[highlightedIndex].value); + setOpen(false); + break; + case 'Tab': + setOpen(false); + break; + default: + break; + } + }; + + const isTop = placement === 'top'; + + // Gooey: the edge facing the panel snaps flat (panel attached) then rounds + // back once the panel pulls away — the two pinch apart. + const kf = open ? [0, 0, 12] : [12, 0, 12]; + const kfT: Transition = reduce + ? { duration: 0 } + : open + ? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT } + : { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT }; + const flatT: Transition = { duration: 0 }; + + const nearGap = open ? 8 : 0; + const nearRadius = open ? 12 : 0; + const gapT: Transition = open + ? { type: 'spring', duration: 0.6, bounce: 0.5, delay: 0.12 } + : { type: 'spring', duration: 0.3, bounce: 0.1 }; + const radiusT: Transition = open + ? { duration: 0.3, ease: EASE_OUT, delay: 0.14 } + : { duration: 0.16, ease: EASE_OUT }; + const instant: Transition = { duration: 0 }; + return (
{label && ( @@ -55,50 +228,157 @@ export function Select({ {label} )} - - - - - + + + + + +
+ + {/* Portaled to so the panel always renders above cards, tables, and + other stacking contexts — it can't be clipped/hidden by an ancestor. */} + {createPortal( + round; far corners stay rounded + borderTopLeftRadius: isTop ? 12 : nearRadius, + borderTopRightRadius: isTop ? 12 : nearRadius, + borderBottomLeftRadius: isTop ? nearRadius : 12, + borderBottomRightRadius: isTop ? nearRadius : 12, + } + } + transition={ + reduce + ? { duration: 0.12 } + : { + opacity: open ? { duration: 0.18 } : { duration: 0.16, delay: 0.12 }, + height: open + ? { type: 'spring', duration: 0.42, bounce: 0.14 } + : { duration: 0.26, ease: EASE_OUT, delay: 0.14 }, + marginTop: isTop ? instant : gapT, + marginBottom: isTop ? gapT : instant, + borderTopLeftRadius: isTop ? instant : radiusT, + borderTopRightRadius: isTop ? instant : radiusT, + borderBottomLeftRadius: isTop ? radiusT : instant, + borderBottomRightRadius: isTop ? radiusT : instant, + } + } + style={{ + position: 'fixed', + left: rect?.left ?? 0, + width: rect?.width ?? 0, + top: isTop ? undefined : (rect?.bottom ?? 0), + bottom: isTop ? window.innerHeight - (rect?.top ?? 0) : undefined, + transformOrigin: isTop ? 'bottom' : 'top', + overflow: 'hidden', + pointerEvents: open ? 'auto' : 'none', + }} + // flush against the trigger, then separates into its own rounded pill; + // sits above or below depending on available space + className="z-50 border border-border bg-popover shadow-lg shadow-black/[0.03] dark:shadow-black/40" > - {options.map((option) => ( - onValueChange(option.value)} - className={cn( - 'cursor-pointer whitespace-normal break-words py-2', - value === option.value && - 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary', - )} - > - {option.label} - - ))} - - + + {options.map((option, index) => { + const selected = option.value === value; + const highlighted = index === highlightedIndex; + return ( + + + + ); + })} + + , + document.body, + )}
); } diff --git a/src/client/hooks/use-job-detail.ts b/src/client/hooks/use-job-detail.ts index ba67efb..515b268 100644 --- a/src/client/hooks/use-job-detail.ts +++ b/src/client/hooks/use-job-detail.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; import { api } from '@client/lib/api'; import type { JobDetail } from '@shared/schema'; @@ -8,11 +9,15 @@ export function useJobDetail(id: string) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const [isRetrying, setIsRetrying] = useState(false); + const [isRerunning, setIsRerunning] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const pollTimeout = useRef(null); const etag = useRef(null); const latestJob = useRef(null); - const isTerminal = (candidate: JobDetail | null) => candidate?.status === 'done' || candidate?.status === 'failed' || candidate?.status === 'superseded'; + const terminalStatuses: string[] = ['done', 'failed', 'superseded', 'cancelled']; + const isTerminal = (candidate: JobDetail | null) => !!candidate && terminalStatuses.includes(candidate.status); const getPollDelay = (candidate: JobDetail | null) => { if (!candidate || isTerminal(candidate)) return null; @@ -87,11 +92,67 @@ export function useJobDetail(id: string) { } }; + const handleRerun = async () => { + if (!job) return; + setIsRerunning(true); + const t = toast.loading('Starting a fresh review…'); + try { + const response = await api.rerunJob(job.id); + toast.success('Fresh review started.', { id: t }); + navigate(`/jobs/${response.job.id}`); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to rerun job.'; + toast.error('Could not start a fresh review.', { id: t, description: msg }); + setError(msg); + } finally { + setIsRerunning(false); + } + }; + + const handleStop = async () => { + if (!job) return; + setIsStopping(true); + const t = toast.loading('Stopping review…'); + try { + await api.stopJob(job.id); + toast.success('Review stopped.', { id: t }); + await fetchJob(); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to stop job.'; + toast.error('Could not stop the review.', { id: t, description: msg }); + setError(msg); + } finally { + setIsStopping(false); + } + }; + + const handleDelete = async () => { + if (!job) return; + setIsDeleting(true); + const t = toast.loading('Deleting job…'); + try { + await api.deleteJob(job.id); + toast.success('Job deleted.', { id: t }); + navigate('/jobs'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to delete job.'; + toast.error('Could not delete the job.', { id: t, description: msg }); + setError(msg); + setIsDeleting(false); + } + }; + return { job, error, isRetrying, + isRerunning, + isStopping, + isDeleting, handleRetry, + handleRerun, + handleStop, + handleDelete, fetchJob }; } diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index baae7e7..85666cb 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -1,6 +1,5 @@ import type { AuthSessionResponse, - DlqResponse, JobDetailResponse, JobsResponse, ModelConfigsResponse, @@ -11,7 +10,7 @@ import type { SyncReposResponse, UpdatesEmailResponse, } from '@shared/api'; -import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; +import type { LlmApiFormat, LlmProvider, RepoConfig, ReviewSettings } from '@shared/schema'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -24,7 +23,6 @@ function pathSegment(value: string) { } type QueryValue = string | number | boolean | null | undefined; -export type ModelConfigPayload = Pick; export type ProviderPayload = { name: string; apiFormat: LlmApiFormat; @@ -174,6 +172,21 @@ export const api = { method: 'POST', }); }, + rerunJob(id: string) { + return request(`/api/jobs/${pathSegment(id)}/rerun`, { + method: 'POST', + }); + }, + stopJob(id: string) { + return request(`/api/jobs/${pathSegment(id)}/stop`, { + method: 'POST', + }); + }, + deleteJob(id: string) { + return request(`/api/jobs/${pathSegment(id)}`, { + method: 'DELETE', + }); + }, getRepos() { return request('/api/repos'); }, @@ -189,21 +202,6 @@ export const api = { method: 'POST', }); }, - getDlqMessages(limit = 20) { - return request(`/api/dlq?limit=${limit}`); - }, - replayDlqMessages(leaseIds: string[]) { - return request<{ replayedCount: number }>('/api/dlq/replay', { - method: 'POST', - body: JSON.stringify({ lease_ids: leaseIds }), - }); - }, - purgeDlqMessages(leaseIds: string[]) { - return request<{ purged: number }>('/api/dlq/purge', { - method: 'POST', - body: JSON.stringify({ lease_ids: leaseIds }), - }); - }, updateRepoConfig(owner: string, repo: string, config: RepoConfigPatch) { return request<{ ok: boolean }>(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`, { method: 'PATCH', @@ -218,17 +216,6 @@ export const api = { method: 'POST', }); }, - updateModelConfig(id: string, config: ModelConfigPayload) { - return request<{ ok: boolean; config: ModelConfig }>(`/api/models/${pathSegment(id)}`, { - method: 'PATCH', - body: JSON.stringify(config), - }); - }, - deleteModelConfig(id: string) { - return request<{ ok: boolean }>(`/api/models/${pathSegment(id)}`, { - method: 'DELETE', - }); - }, createProvider(config: ProviderPayload) { return request<{ provider: LlmProvider }>('/api/models/providers', { method: 'POST', @@ -246,11 +233,6 @@ export const api = { method: 'DELETE', }); }, - testModelConfig(id: string) { - return request<{ ok: boolean; modelUsed: string; provider: string; inputTokens: number; outputTokens: number }>(`/api/models/${pathSegment(id)}/test`, { - method: 'POST', - }); - }, getGlobalConfig() { return request<{ config: RepoConfig['model'] }>('/api/models/global'); }, @@ -260,4 +242,13 @@ export const api = { body: JSON.stringify(config), }); }, + getReviewSettings() { + return request<{ settings: ReviewSettings }>('/api/settings'); + }, + updateReviewSettings(settings: ReviewSettings) { + return request<{ ok: boolean; settings: ReviewSettings }>('/api/settings', { + method: 'PATCH', + body: JSON.stringify(settings), + }); + }, }; diff --git a/src/client/lib/ease.ts b/src/client/lib/ease.ts new file mode 100644 index 0000000..8abd8d1 --- /dev/null +++ b/src/client/lib/ease.ts @@ -0,0 +1 @@ +export const EASE_OUT = [0.16, 1, 0.3, 1] as const; diff --git a/src/client/lib/utils.ts b/src/client/lib/utils.ts index fb2efda..6db9aae 100644 --- a/src/client/lib/utils.ts +++ b/src/client/lib/utils.ts @@ -10,3 +10,21 @@ export function fmtNumber(n: number) { if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`; return n.toLocaleString(); } + +export function formatDuration(ms: number | null | undefined): string { + if (ms == null) return ''; + // Sub-minute: show one decimal second so sub-second work (e.g. a 724ms file review) reads as + // "0.7s" rather than flooring to a misleading "0s". + if (ms < 60_000) { + return `${(ms / 1000).toFixed(1)}s`; + } + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) { + return `${minutes}m ${seconds}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} diff --git a/src/client/main.tsx b/src/client/main.tsx index 0bb5880..8472f27 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -19,6 +19,7 @@ import './app.css'; import { ThemeProvider } from './lib/theme'; import { useIsDarkMode } from './hooks/use-is-dark-mode'; +import { SmoothScroll } from './components/motion/smooth-scroll'; function ToasterWrapper() { const isDark = useIsDarkMode(); @@ -26,7 +27,6 @@ function ToasterWrapper() { - - + + + + , ); diff --git a/src/client/pages/dashboard.tsx b/src/client/pages/dashboard.tsx index 67f05a4..792baa7 100644 --- a/src/client/pages/dashboard.tsx +++ b/src/client/pages/dashboard.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import { api } from '@client/lib/api'; import type { StatsPayload } from '@shared/schema'; import type { JobSummary } from '@shared/schema'; -import { ArrowRight } from 'lucide-react'; +import { ArrowRight, GitPullRequest } from 'lucide-react'; import { JobsTable } from '@client/components/shared/jobs-table'; +import { EmptyState } from '@client/components/shared/empty-state'; import { PageHeaderActions } from '@client/components/shared/page-header-actions'; import { Link } from 'react-router-dom'; @@ -47,7 +48,6 @@ export function DashboardPage() {
- + {(loading || recentJobs.length > 0) && ( + + )} + + {!loading && recentJobs.length === 0 && ( + } + title="No jobs yet" + description="Your pull request analysis logs will appear here" + hints={[ + 'Once you open a PR in any of the connected repos, analysis triggers automatically', + 'To trigger manually, comment @codra on any PR', + ]} + linkAction={{ + label: 'See how to interact with Codra', + href: 'https://github.com/devarshishimpi/codra#readme', + }} + className="rounded-none border-0" + /> + )} {!loading && recentJobs.length > 0 && (
@@ -96,3 +115,4 @@ export function DashboardPage() {
); } + diff --git a/src/client/pages/job-detail.tsx b/src/client/pages/job-detail.tsx index 1ec8489..93c08bd 100644 --- a/src/client/pages/job-detail.tsx +++ b/src/client/pages/job-detail.tsx @@ -10,7 +10,16 @@ import { Alert } from '@client/components/ui/alert'; export function JobDetailPage() { const { id = '' } = useParams(); - const { job, error, isRetrying, handleRetry } = useJobDetail(id); + const { + job, + error, + isRerunning, + isStopping, + isDeleting, + handleRerun, + handleStop, + handleDelete, + } = useJobDetail(id); if (!job) { return ; @@ -18,10 +27,14 @@ export function JobDetailPage() { return (
- {error && ( diff --git a/src/client/pages/job-logs.tsx b/src/client/pages/job-logs.tsx index 2e3d071..d5dbda7 100644 --- a/src/client/pages/job-logs.tsx +++ b/src/client/pages/job-logs.tsx @@ -9,11 +9,7 @@ import { JobDetailSkeleton } from '@client/components/features/job-detail/job-sk import { Alert } from '@client/components/ui/alert'; import type { FileReviewRecord } from '@shared/schema'; -function fmtMs(ms: number | null) { - if (ms === null) return null; - if (ms < 1000) return `${ms}ms`; - return `${(ms / 1000).toFixed(1)}s`; -} +import { formatDuration } from '@client/lib/utils'; function fmtK(n: number | null) { if (n === null) return null; @@ -37,7 +33,7 @@ const STATUS_META: Record([]); @@ -19,33 +17,26 @@ export function JobsPage() { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - // System failures (DLQ) state - const [dlqMessages, setDlqMessages] = useState([]); - const [processingDlq, setProcessingDlq] = useState(false); const [filters, setFilters] = useState({ status: '', verdict: '', search: '', page: 1, }); - const limit = 20; + const [itemsPerPage, setItemsPerPage] = useState(10); - const load = async (isManual = false) => { + const load = useCallback(async (isManual = false) => { if (isManual) setRefreshing(true); try { - const [jobsRes, dlqRes] = await Promise.all([ - api.getJobs({ - status: filters.status || undefined, - verdict: filters.verdict || undefined, - search: filters.search || undefined, - limit, - offset: (filters.page - 1) * limit, - }), - api.getDlqMessages(20).catch(() => ({ messages: [] })) - ]); + const jobsRes = await api.getJobs({ + status: filters.status || undefined, + verdict: filters.verdict || undefined, + search: filters.search || undefined, + limit: itemsPerPage, + offset: (filters.page - 1) * itemsPerPage, + }); setJobs(jobsRes.jobs); setTotal(jobsRes.total); - setDlqMessages(dlqRes.messages); setError(null); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load jobs.'); @@ -53,55 +44,20 @@ export function JobsPage() { setLoading(false); setRefreshing(false); } - }; + }, [filters, itemsPerPage]); - const handleReplayDlq = async (leaseIds: string[]) => { - if (processingDlq) return; - setProcessingDlq(true); - try { - await api.replayDlqMessages(leaseIds); - // Small delay to let the queue process a bit before refreshing - setTimeout(() => load(true), 1000); - } catch (e) { - setError(e instanceof Error ? e.message : 'Replay failed.'); - } finally { - setProcessingDlq(false); - } - }; - - const handlePurgeDlq = async (leaseIds: string[]) => { - if (processingDlq || !confirm('Permanently discard these interrupted jobs?')) return; - setProcessingDlq(true); - try { - await api.purgeDlqMessages(leaseIds); - await load(true); - } catch (e) { - setError(e instanceof Error ? e.message : 'Purge failed.'); - } finally { - setProcessingDlq(false); - } - }; - - usePolling(load, 15_000, [filters]); - - const totalPages = Math.ceil(total / limit); + usePolling(load, 15_000, [filters, itemsPerPage]); + const totalPages = Math.ceil(total / itemsPerPage); return ( -
+
+ {/* ── Header ─────────────────────────────────── */} - {dlqMessages.length > 0 && ( - - - {dlqMessages.length} Action Required - - )} - - - -
- - - - - - - - - - - {dlqMessages.map((msg) => ( - - - - - - - ))} - -
TimeRetriesContextAction
- {msg.metadata.timestamp - ? new Date(msg.metadata.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) - : '—'} - - - {msg.metadata.attempts}× - - -
- - - {typeof msg.body === 'object' ? JSON.stringify(msg.body) : String(msg.body)} - -
-
-
- - -
-
-
- - )} - - {/* Filters */} -
-
-
- )} - - - {/* ── System Information ────────────────────────────────────────────── */} -
{/* Version row */} -
- - - -
+
+
+

Version

-

Installed Codra release

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

Releases

-

Browse all versions and release notes on GitHub

+

License

+ {pkg.license} +
+ +
+ + {/* Links */} +
+ {[ + { href: `${pkg.repository.url.replace(/\.git$/, '')}/releases/`, label: 'Releases', sub: 'Version history & notes' }, + { href: pkg.homepage, label: 'Homepage', sub: 'codra.run' }, + { href: pkg.bugs.url, label: 'Report an issue', sub: 'GitHub issue tracker' }, + ].map(({ href, label, sub }) => ( - View releases - + + + {label} + + + {sub} + -
- + ))}
diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx index ae77c7d..f0c9963 100644 --- a/src/client/pages/stats.tsx +++ b/src/client/pages/stats.tsx @@ -5,24 +5,11 @@ import { Bar, BarChart, CartesianGrid, - Line, - LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; -import { - Activity, - BarChart3, - Bot, - Gauge, - GitBranch, - RefreshCw, - Server, - TrendingUp, -} from 'lucide-react'; - import { TimeRangeSelect } from '@client/components/features/stats/time-range-select'; import { PageHeaderActions } from '@client/components/shared/page-header-actions'; import { PageHeader } from '@client/components/layout/page-header'; @@ -36,21 +23,16 @@ import { fmtNumber } from '@client/lib/utils'; import type { StatsPayload } from '@shared/schema'; const CHART = { - primary: '#84cc16', + primary: '#65a30d', primaryDark: '#e0fe56', comments: '#0ea5e9', - warning: '#f59e0b', - danger: '#ef4444', - quiet: '#64748b', - violet: '#8b5cf6', -}; - -const axisProps = { - fontSize: 11, - tickLine: false, - tickMargin: 10, - axisLine: false, - tick: { fontFamily: 'var(--font-sans)', fill: 'var(--muted-foreground)' } as const, + commentsDark: '#38bdf8', + warning: '#d97706', + warningDark: '#fbbf24', + danger: '#dc2626', + dangerDark: '#f87171', + quiet: '#94a3b8', + quietDark: '#64748b', }; function formatDay(value: string) { @@ -63,11 +45,6 @@ function formatCompact(value: number) { return value >= 1000 ? fmtNumber(value) : value.toLocaleString(); } -function ratio(numerator: number, denominator: number, decimals = 1) { - if (!denominator) return '0'; - return (numerator / denominator).toFixed(decimals); -} - function percent(numerator: number, denominator: number) { if (!denominator) return 0; return Math.round((numerator / denominator) * 100); @@ -111,135 +88,163 @@ function GraphSectionTitle() { function GraphShell({ title, - eyebrow, - icon: Icon, children, className = '', }: { title: string; - eyebrow: string; - icon: typeof Activity; children: ReactNode; className?: string; }) { return ( -
-
-
-

{eyebrow}

-

{title}

-
- - - +
+
+

{title}

- {children} +
{children}
); } -function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days: number; isDark: boolean }) { +function MetricsGrid({ stats, isDark }: { stats: StatsPayload; isDark: boolean }) { const color = isDark ? CHART.primaryDark : CHART.primary; - const tokenTrend = stats.trend.map((day) => ({ - ...day, - tokens: day.inputTokens + day.outputTokens, - tokenDensity: Math.round((day.inputTokens + day.outputTokens) / Math.max(day.jobs, 1)), - commentRate: Number(ratio(day.comments, Math.max(day.jobs, 1), 1)), - })); - const cumulativeTrend = stats.trend.reduce<{ day: string; reviews: number; comments: number }[]>((acc, day) => { - const prev = acc[acc.length - 1]; - acc.push({ - day: day.day, - reviews: (prev?.reviews ?? 0) + day.jobs, - comments: (prev?.comments ?? 0) + day.comments, - }); - return acc; - }, []); + const commentsColor = isDark ? CHART.commentsDark : CHART.comments; + const inputColor = isDark ? CHART.commentsDark : CHART.comments; + const outputColor = isDark ? CHART.warningDark : CHART.warning; + const dangerColor = isDark ? CHART.dangerDark : CHART.danger; + const quietColor = isDark ? CHART.quietDark : CHART.quiet; const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); + // Theme-aware chart chrome. CSS variables don't reliably resolve inside + // Recharts SVG text, so use explicit colors keyed off the active theme. + const axisColor = isDark ? 'rgba(228,228,231,0.62)' : 'rgba(63,63,70,0.78)'; + const gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; + const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; + const axisProps = { + fontSize: 11, + tickLine: false, + tickMargin: 8, + axisLine: false, + tick: { fontFamily: 'var(--font-sans)', fill: axisColor }, + } as const; + // Let Recharts thin the labels by available space (respecting minTickGap) + // rather than a fixed stride keyed off the range — the trend array can have + // far fewer points than `days`, which would hide every label but the first. + const xAxisProps = { + dataKey: 'day', + tickFormatter: formatDay, + interval: 'preserveStartEnd' as const, + minTickGap: 24, + }; + + const STATUS_COLOR: Record = { + done: color, + running: commentsColor, + queued: quietColor, + failed: dangerColor, + superseded: quietColor, + cancelled: quietColor, + }; + const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); + return ( -
+
-
- -
+
+ +
- - - - - } /> - - - - -
-
- - -
- - + - - - + + + + + + + - - - - } /> - + + + + } cursor={{ stroke: color, strokeDasharray: '4 4' }} /> + +
-
-
- -
+ +
- - - - - } /> - - + + + + + } cursor={{ fill: cursorColor }} /> + +
+
- -
- - - - - - } /> - - - +
+ +
+
+ {stats.statuses.map((s) => ( +
+ ))} +
+
+ {stats.statuses.map((s) => ( +
+
+ + {s.status} +
+ {s.count} +
+ ))} +
-
-
- -
- {stats.topRepos.slice(0, 7).map((repo) => ( + +
+ {stats.topRepos.slice(0, 8).map((repo) => (
-
+
{repo.owner}/{repo.repo}
-
+
@@ -249,16 +254,16 @@ function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days:
- -
- {stats.models.slice(0, 7).map((model) => ( + +
+ {stats.models.slice(0, 8).map((model) => (
-
+
{modelName(model.modelUsed)}
-
-
+
+
{model.calls} @@ -267,175 +272,56 @@ function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days:
-
); } -function PanelHeader({ - label, - value, - detail, -}: { - label: string; - value?: string; - detail?: string; -}) { +function GraphCardSkeleton({ className = '' }: { className?: string }) { return ( -
-
- - - -
-

{label}

- {detail &&

{detail}

} -
+
+
+
- {value && ( - - {value} - - )} -
+
+ +
+
); } -function ReviewFlowCard({ - stats, - days, - isDark, -}: { - stats: StatsPayload; - days: number; - isDark: boolean; -}) { - const color = isDark ? CHART.primaryDark : CHART.primary; - const totalReviews = stats.trend.reduce((sum, day) => sum + day.jobs, 0); - const maxDay = stats.trend.reduce( - (best, day) => (day.jobs > best.jobs ? day : best), - stats.trend[0] ?? { day: '', jobs: 0 }, - ); - +function GraphBarCardSkeleton({ className = '' }: { className?: string }) { return ( -
-
-
- -
- - - - - - - - - - - - - - - - } cursor={{ stroke: color, strokeDasharray: '4 4' }} /> - - - - -
-
- - + ))}
-
+ ); } -function ReviewFlowSkeleton() { +function MetricsGridSkeleton() { return ( -
-
-
-
- - - -
- - -
-
-
-
-
- - -
-
- - - -
-
+
+ ); @@ -484,12 +370,9 @@ export function StatsPage() { {error && {error}} {stats ? ( - <> - - - + ) : ( - loading && + )}
); diff --git a/src/server/app.ts b/src/server/app.ts index 486cb53..4265f70 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -10,8 +10,8 @@ import { createAuthApiRouter } from '@server/routes/api/auth'; import { createJobsRouter } from '@server/routes/api/jobs'; import { createReposRouter } from '@server/routes/api/repos'; import { createStatsRouter } from '@server/routes/api/stats'; -import { createDlqRouter } from '@server/routes/api/dlq'; import { createModelsRouter } from '@server/routes/api/models'; +import { createSettingsRouter } from '@server/routes/api/settings'; async function serveIndex(c: Context) { return c.env.ASSETS.fetch(new URL('/index.html', c.req.url)); @@ -34,8 +34,8 @@ export function createApp() { app.route('/api/jobs', createJobsRouter()); app.route('/api/repos', createReposRouter()); app.route('/api/stats', createStatsRouter()); - app.route('/api/dlq', createDlqRouter()); app.route('/api/models', createModelsRouter()); + app.route('/api/settings', createSettingsRouter()); app.get('/login', serveIndex); app.get('/', serveIndex); // Unauthenticated landing page diff --git a/src/server/core/diff.ts b/src/server/core/diff.ts index 64a5641..76e6331 100644 --- a/src/server/core/diff.ts +++ b/src/server/core/diff.ts @@ -32,15 +32,22 @@ const defaultSkipMatchers = ['**/*.lock', '**/package-lock.json', '**/pnpm-lock. picomatch(pattern, { dot: true }), ); -export function parseUnifiedDiff(rawDiff: string): FileDiff[] { - const lines = rawDiff.replace(/\r\n/g, '\n').split('\n'); +export function isReviewableFile(path: string, customMatchers: ReturnType[]) { + if (defaultSkipMatchers.some((matcher) => matcher(path))) return false; + if (customMatchers.some((matcher) => matcher(path))) return false; + return true; +} + +export function parseUnifiedDiff(rawDiff: string, reviewConfig?: RepoConfig['review']): FileDiff[] { const files: FileDiff[] = []; + const customMatchers = reviewConfig?.skip_files?.map((pattern) => picomatch(pattern, { dot: true })) ?? []; let currentFile: FileDiff | null = null; let currentHunk: DiffHunk | null = null; let oldLine = 0; let newLine = 0; let position = 0; + let isIgnored = false; const pushCurrentFile = () => { if (currentFile) { @@ -51,13 +58,29 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] { oldLine = 0; newLine = 0; position = 0; + isIgnored = false; }; - for (const line of lines) { + let startIndex = 0; + const length = rawDiff.length; + + while (startIndex < length) { + let endIndex = rawDiff.indexOf('\n', startIndex); + if (endIndex === -1) { + endIndex = length; + } + + let line = rawDiff.substring(startIndex, endIndex); + if (line.charCodeAt(line.length - 1) === 13) { + line = line.slice(0, -1); + } + + startIndex = endIndex + 1; + if (line.startsWith('diff --git ')) { pushCurrentFile(); - const parts = line.split(' '); - const bPath = parts[parts.length - 1]; + const lastSpace = line.lastIndexOf(' '); + const bPath = line.substring(lastSpace + 1); const path = bPath.startsWith('b/') ? bPath.slice(2) : bPath; currentFile = { @@ -69,6 +92,10 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] { lineCount: 0, hunks: [], }; + + if (reviewConfig) { + isIgnored = !isReviewableFile(path, customMatchers); + } continue; } @@ -77,12 +104,16 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] { } if (line.startsWith('rename from ')) { - currentFile.previousPath = line.slice('rename from '.length); + currentFile.previousPath = line.slice(12); continue; } if (line.startsWith('rename to ')) { - currentFile.path = line.slice('rename to '.length); + const nextPath = line.slice(10); + currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath; + if (reviewConfig) { + isIgnored = !isReviewableFile(currentFile.path, customMatchers); + } continue; } @@ -93,21 +124,30 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] { if (line.startsWith('deleted file mode ')) { currentFile.isDeleted = true; + isIgnored = true; continue; } if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) { currentFile.isBinary = true; - continue; - } - - if (line.startsWith('--- ')) { + isIgnored = true; continue; } if (line.startsWith('+++ ')) { const nextPath = line.slice(4); currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath; + if (reviewConfig) { + isIgnored = !isReviewableFile(currentFile.path, customMatchers); + } + continue; + } + + if (isIgnored) { + continue; + } + + if (line.startsWith('--- ')) { continue; } @@ -132,7 +172,7 @@ export function parseUnifiedDiff(rawDiff: string): FileDiff[] { } const prefix = line[0]; - if (![' ', '+', '-'].includes(prefix)) { + if (prefix !== ' ' && prefix !== '+' && prefix !== '-') { continue; } @@ -282,3 +322,62 @@ export function truncateFileDiff(file: FileDiff, maxLines: number): FileDiff { originalLineCount: file.lineCount, }; } + +export function chunkFileDiff(file: FileDiff, maxLinesPerChunk: number): FileDiff[] { + if (file.lineCount <= maxLinesPerChunk) { + return [file]; + } + + const chunks: FileDiff[] = []; + let currentHunks: DiffHunk[] = []; + let currentLines = 0; + + for (const hunk of file.hunks) { + let linesRemainingInHunk = hunk.lines; + + while (linesRemainingInHunk.length > 0) { + const roomInChunk = maxLinesPerChunk - currentLines; + + if (roomInChunk <= 0) { + chunks.push({ + ...file, + hunks: currentHunks, + lineCount: currentLines, + isTruncated: true, + originalLineCount: file.lineCount, + }); + currentHunks = []; + currentLines = 0; + continue; + } + + if (linesRemainingInHunk.length <= roomInChunk) { + currentHunks.push({ + ...hunk, + lines: linesRemainingInHunk, + }); + currentLines += linesRemainingInHunk.length; + linesRemainingInHunk = []; + } else { + currentHunks.push({ + ...hunk, + lines: linesRemainingInHunk.slice(0, roomInChunk), + }); + currentLines += roomInChunk; + linesRemainingInHunk = linesRemainingInHunk.slice(roomInChunk); + } + } + } + + if (currentHunks.length > 0) { + chunks.push({ + ...file, + hunks: currentHunks, + lineCount: currentLines, + isTruncated: true, + originalLineCount: file.lineCount, + }); + } + + return chunks; +} diff --git a/src/server/core/github.ts b/src/server/core/github.ts index 28dee67..3abb178 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -196,9 +196,22 @@ export class GitHubClient { private readonly tracker?: { incrementSubrequests(count?: number): void }, ) {} + // In-memory token cache scoped to this client instance (i.e. one Worker invocation). Without it, + // every GitHub request re-read the token from KV -- a wasted subrequest per call. A finalize or + // review invocation makes many GitHub calls, so that repeated KV read pushed the invocation toward + // the Workers-Free 50-subrequest cap (finalize could tip over it right before posting the review). + private memoToken: InstallationTokenCacheRecord | null = null; + async getInstallationToken(): Promise { + // Reuse the in-memory token while it's comfortably unexpired (invocations are < ~120s; tokens + // last ~1h, so this holds for the whole invocation) -- no KV read, no network call. + if (this.memoToken?.token && new Date(this.memoToken.expiresAt).getTime() > Date.now() + 60_000) { + return this.memoToken.token; + } + const cached = await readCachedInstallationToken(this.env, this.installationId, this.tracker); if (cached?.token) { + this.memoToken = cached; return cached.token; } @@ -229,10 +242,12 @@ export class GitHubClient { } const data = (await response.json()) as { token: string; expires_at: string }; - await writeCachedInstallationToken(this.env, this.installationId, { + const record: InstallationTokenCacheRecord = { token: data.token, expiresAt: data.expires_at, - }, this.tracker); + }; + await writeCachedInstallationToken(this.env, this.installationId, record, this.tracker); + this.memoToken = record; return data.token; }); @@ -462,7 +477,7 @@ export class GitHubClient { title: string; summary: string; status?: 'in_progress' | 'completed'; - conclusion?: 'success' | 'neutral' | 'failure'; + conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled'; }, ) { return withRetry(`updateCheckRun ${owner}/${repo} ${checkRunId}`, async () => { @@ -552,6 +567,37 @@ export class GitHubClient { }); } + // Returns a review this app already posted on the given commit, if one exists. Used by finalize + // ONLY when re-running after a prior attempt reached the posting stage, to avoid double-posting a + // review when the earlier invocation died in the narrow window between createReview() succeeding + // and completeJob() recording the review id (e.g. a subrequest-budget error). One GET, first page + // of 100 -- enough for the guard on any realistic PR (a PR with >100 reviews is pathological). + async findBotReviewForCommit( + owner: string, + repo: string, + pullNumber: number, + commitSha: string, + botLogin: string, + ): Promise<{ id: number } | null> { + return withRetry(`findBotReviewForCommit ${owner}/${repo}#${pullNumber}`, async () => { + const response = await this.requestAndCheck( + `${repoApiPath(owner, repo)}/pulls/${pullNumber}/reviews?per_page=100`, + ); + const reviews = (await response.json()) as Array<{ + id: number; + commit_id?: string | null; + user?: { login?: string | null } | null; + }>; + const login = botLogin.toLowerCase(); + const match = reviews.find( + (review) => + review.commit_id === commitSha && + (review.user?.login ?? '').toLowerCase().startsWith(login), + ); + return match ? { id: match.id } : null; + }); + } + async ensureLabel(owner: string, repo: string, name: string, color: string) { return withRetry(`ensureLabel ${owner}/${repo} ${name}`, async () => { const listResponse = await this.request(`${repoApiPath(owner, repo)}/labels/${encodeURIComponent(name)}`); diff --git a/src/server/core/job-recovery.ts b/src/server/core/job-recovery.ts index c00c193..5cf0405 100644 --- a/src/server/core/job-recovery.ts +++ b/src/server/core/job-recovery.ts @@ -13,6 +13,10 @@ export async function recoverJobs(env: AppBindings) { jobId, deliveryId: crypto.randomUUID(), phase: 'review', + // The job's previous Workflow instance (keyed on jobId) is dead but still exists, so a + // same-id create() would be dropped as a duplicate. Force a fresh instance keyed on the new + // deliveryId so the recovered job actually resumes instead of climbing recovery_count. + forceFreshInstance: true, }); } @@ -28,17 +32,43 @@ export async function recoverJobs(env: AppBindings) { } export async function completeTerminalCheckRuns(env: AppBindings) { - const jobs = await getTerminalJobsNeedingCheckRunCompletion(env); + // Limit to 1 to avoid Cloudflare's 50 subrequest limit per invocation, + // especially when called opportunistically via waitUntil during API polling. + // Each job requires multiple subrequests (KV, GitHub API, Hyperdrive). + const jobs = await getTerminalJobsNeedingCheckRunCompletion(env, 1); for (const job of jobs) { if (!job.check_run_id) continue; try { const github = new GitHubService(env, job.installation_id); + + let conclusion: 'success' | 'neutral' | 'failure' | 'cancelled'; + let title: string; + let summary: string; + if (job.status === 'done') { + // A completed review whose inline check-run update didn't land (e.g. finalize ran out of + // subrequest budget). Reconstruct the same conclusion finalize would have posted. + const partial = (job.error_msg ?? '').startsWith('Partial review'); + conclusion = partial ? 'failure' : (job.verdict === 'approve' ? 'success' : 'neutral'); + title = partial ? 'Review partially failed' : (job.verdict === 'approve' ? 'LGTM' : 'Comments posted'); + summary = job.error_msg ?? `${job.comment_count ?? 0} inline comments across ${job.file_count ?? 0} files.`; + } else { + const checkRunPresentation = { + superseded: { conclusion: 'neutral' as const, title: 'Review superseded', summary: 'Superseded by a newer commit or job.' }, + cancelled: { conclusion: 'cancelled' as const, title: 'Review stopped', summary: 'Stopped by user.' }, + failed: { conclusion: 'failure' as const, title: 'Review failed', summary: 'Review failed.' }, + }; + const presentation = checkRunPresentation[job.status as keyof typeof checkRunPresentation] ?? checkRunPresentation.failed; + conclusion = presentation.conclusion; + title = presentation.title; + summary = job.error_msg ?? presentation.summary; + } + 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.'), + conclusion, + title, + summary, }); await markJobCheckRunCompleted(env, job.id); } catch (error) { diff --git a/src/server/core/model-output.ts b/src/server/core/model-output.ts index d2298d5..3ad16f8 100644 --- a/src/server/core/model-output.ts +++ b/src/server/core/model-output.ts @@ -398,7 +398,7 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): { while (current !== prev) { prev = current; current = current - .replace(/^([\u{1F300}-\u{1F9FF}]|\[QUALITY\]|\[SECURITY\]|\[BUG\]|\[PERFORMANCE\]|\[CORRECTNESS\]|\[P[0-3]\]|\[NIT\]|QUALITY|SECURITY|BUG|P[0-3]|NIT|[:\-\s\uFE0F]|[^\w\s])+/giu, '') + .replace(/^(?:[^\w\s]+|(?:QUALITY|SECURITY|BUG|PERFORMANCE|CORRECTNESS|P[0-3]|NIT)\b)+/giu, '') .replace(/\n\s*/g, ' ') // Flatten newlines in titles/snippets .trim(); } diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 03a4036..979406b 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -1,10 +1,31 @@ import { logger } from './logger'; import { isSupportedGitHubWebhookEvent, type GitHubWebhookEventName, type GitHubWebhookPayload, type IssueCommentWebhookPayload, type PullRequestWebhookPayload } from '@shared/github'; import { defaultRepoConfig, normalizeModelId, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema'; +import { isTimeoutMessage, matchesAnyTransientSubstring } from '@shared/transient-errors'; import type { AppBindings } from '@server/env'; -import { getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; +import { bulkInheritFileReviews, bulkMarkFilesFailed, 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 { + claimJobLease, + completeJob, + completePreparationStep, + failJob, + findExistingJobForHead, + getJobForProcessing, + getOtherRunningJobsCount, + heartbeatJobLease, + insertJob, + mapJob, + markJobCheckRunCompleted, + markJobContinuationQueued, + resetJobContinuationCount, + releaseJobLease, + setJobPullRequestMeta, + setJobWorkflowInstance, + supersedeOlderJobs, + updateJobCheckRun, + updateJobStep, +} from '@server/db/jobs'; import { filterReviewableFiles, parseUnifiedDiff } from './diff'; import { GitHubService } from '../services/github'; @@ -14,38 +35,137 @@ import { FormatterService } from '../services/formatter'; import { TokenTracker } from './token-tracker'; import { loadRepoConfig } from './config'; import { getWebhookDelivery } from '@server/db/webhook-deliveries'; +import { sendTelemetryEvent } from './telemetry'; +import { getReviewSettings } from '@server/db/app-settings'; +import { REVIEW_CONCURRENCY_LIMITS } from '@shared/schema'; type PersistedReviewJob = ReturnType; -export type ReviewJobRunResult = { action: 'ack' } | { action: 'retry'; delaySeconds: number }; +export type ReviewJobRunResult = + | { action: 'ack' } + | { action: 'retry'; delaySeconds: number } + // jobId is the RESOLVED job id (not the delivery id): mention-triggered jobs don't carry a jobId + // in the queue message, so the workflow can't otherwise know it. The workflow uses it to re-enqueue + // the next phase as a fresh instance. + // + // freshInstance signals the workflow to run the next phase in a BRAND-NEW instance rather than + // continuing this one. It's set when the current instance can't get a usable per-invocation + // subrequest budget anymore: either a subrequest-limit deferral (a long-lived instance has stopped + // hibernating, so its budget never resets) or the transition into finalize (which needs ~20 + // subrequests at once to post the review). A fresh instance's first step always gets a clean budget. + | { action: 'next_phase'; phase: 'prepare' | 'review' | 'finalize'; delaySeconds: number; jobId?: string; freshInstance?: boolean }; -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]; +// Backoff between deferred retries of a file that hit a transient model/provider failure. +// Kept short at first: most observed failures are momentary provider load (Gemini 500/503 +// "high demand") or self-inflicted connection queuing, both of which clear within seconds, +// so a long first delay just makes reviews grind. Later attempts back off harder in case the +// provider really is having an outage. +const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [30, 2 * 60, 5 * 60]; +// Yield used when a review chunk / phase transition MUST run in a fresh Worker invocation to get a +// fresh per-invocation subrequest budget (Workers Free: 50/invocation). Cloudflare only hibernates +// a Workflow -- running -> waiting -> resume in a NEW invocation -- when the step.sleep is long +// enough; a "very short" sleep keeps the instance warm in the SAME invocation, so the real +// subrequest budget accumulates across every chunk until it's exhausted and the whole review loops +// in one invocation until "Too many subrequests" (the observed failure). This yield is deliberately +// long enough to force hibernation so each continuation starts with a clean budget. It is only +// applied when a fresh budget is actually needed (multi-chunk reviews, budget-pressured finalize), +// so small PRs that fit in a single invocation stay fast. +const FRESH_INVOCATION_YIELD_SECONDS = 60; +// Delay between polls of an in-flight Workers AI async batch review. Batches typically complete +// within a few minutes, so poll on a short cadence; a stuck batch is bounded by the shared +// MAX_JOB_CONTINUATIONS ceiling (each poll reschedule counts as a no-progress continuation). +const ASYNC_BATCH_POLL_DELAY_SECONDS = 20; const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 3; +// Belt-and-suspenders ceiling on how many times a job may reschedule the *same* phase without +// completing a single file (see markJobContinuationQueued / resetJobContinuationCount). The +// per-file cap above is the primary bound and resets this counter on any progress, so a healthy +// job never approaches this; it only fires when a job is genuinely wedged (e.g. a provider is +// down for the whole backoff window) and stops it from churning for hours before finalizing. +const MAX_JOB_CONTINUATIONS = 20; +// Finalize gets a much lower reschedule ceiling than review. Unlike review (which makes real +// progress one file at a time and legitimately spans many continuations), finalize is a short, +// self-contained phase: on a fresh invocation it either fits the subrequest budget and posts, or +// it doesn't. Retrying it a few times covers a transient budget miss (a reschedule that lands on a +// genuinely fresh invocation), but if a saturated long-lived workflow instance can't give finalize +// a clean budget, more retries won't help -- so cap them low and fail fast (the check-run reconciler +// and an inheriting re-run recover) instead of churning ~20 min against the shared ceiling. +const MAX_FINALIZE_CONTINUATIONS = 3; +// A job's commit (and therefore its diff) never changes, so the raw diff can be +// cached for the job's entire lifetime instead of being re-fetched from GitHub on +// every prepare/review-chunk/finalize phase. 6h comfortably covers even a job that +// hits every retryable-failure backoff (up to 15 min each, several times over). +const DIFF_CACHE_TTL_SECONDS = 6 * 60 * 60; +// Estimated subrequest cost of reviewing one file, used only to size how many files can +// safely be reviewed concurrently in a chunk given the job's remaining subrequest budget for +// this invocation (see budgetAwareChunkFileLimit below). A file walks a fallback chain of up +// to ~3 models, but the per-model model-config lookup is now cached per invocation +// (ModelService.resolveModel), so the recurring cost per file is ~1 provider call per model +// tried plus the persisted-review write -- roughly 5 in the worst case rather than 9. Lower +// estimate => more files reviewed in parallel per chunk within the same 50-subrequest cap. +// +// Sized to the ~5 worst-case figure above (not padded higher): with the TokenTracker's +// SAFE_MARGIN reserve of 25 the fresh-budget headroom is 25, and 25 / 5 == 5 keeps even the +// highest configured concurrency level (max == 4) fully honored at a healthy budget. Padding +// this to 8 would make floor(25 / 8) == 3 silently cap the "max" slider to 3 -- the exact +// "concurrency slider is dead above medium" regression pinned by chunk-concurrency.spec.ts. +const ESTIMATED_SUBREQUESTS_PER_FILE = 5; + +/** + * How many files a single review chunk may process concurrently: the configured concurrency + * level, capped only by what the invocation's remaining subrequest budget can safely cover. + * + * The cap is deliberately sized so it does NOT silently override the user's chosen concurrency + * at a healthy budget -- that would make the concurrency setting a no-op above the cap. It + * only throttles once earlier failures in this invocation have actually eaten into the budget; + * if there is not enough safe budget for one more file, the chunk yields and resumes in a fresh + * invocation instead of gambling past the margin. Any files a throttled chunk can't reach roll + * into the next chunk. The + * chunk-file-limit-honors-configured-level invariant is pinned by a regression test. + */ +export function budgetAwareFileLimit(remainingSafeBudget: number, configuredChunkFileLimit: number) { + const budgetLimit = Math.floor(remainingSafeBudget / ESTIMATED_SUBREQUESTS_PER_FILE); + return Math.min(configuredChunkFileLimit, budgetLimit); +} function isRetryableFileReviewErrorMessage(message: string | null | undefined) { if (!message) return false; const lower = message.toLowerCase(); + + // Explicitly fail fast for timeouts so they don't loop endlessly, aligning with + // isTransientModelFailure which prevents timeouts from being retried. + if (isTimeoutMessage(lower)) { + return false; + } + return ( + matchesAnyTransientSubstring(lower) || 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') + // Older jobs may have persisted subrequest-budget failures before budget exhaustion became + // a pure chunk-level deferral. Keep retrying those rows instead of treating them as handled. + lower.includes('subrequest') ); } +/** + * Detects Cloudflare's per-invocation subrequest-limit error (Workers Free plan: 50 + * subrequests/invocation). Unlike a provider outage, this clears completely on the next + * invocation, so the correct response is never to fail the whole job or permanently abandon + * a file -- it is to persist whatever progress was made and reschedule the same phase, which + * runs in a fresh invocation with a fresh budget. Because each review chunk reviews and + * persists only a few files (see reviewChunkFileLimit), rescheduling reliably makes forward + * progress and the review grinds to completion instead of dying mid-way. + */ +function isSubrequestBudgetError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return message.toLowerCase().includes('subrequest'); +} + function retryableModelFailureDelaySeconds(failureCount: number | null | undefined) { if (!failureCount || failureCount < 1) return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0]; const index = Math.min(failureCount - 1, RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS.length - 1); @@ -69,10 +189,31 @@ function countsAsHandledFileReview(review: { file_status: string; error_msg: str return !shouldRetryExistingFileReview(review); } +/** + * A file whose review was submitted to the Workers AI async batch queue and is still + * queued/running. Such a row is persisted as 'pending' with the queue request_id; it is neither + * "handled" (it must be polled to completion) nor a failure to retry from scratch. + */ +function isAwaitingAsyncReview(review: { file_status: string; async_request_id?: string | null }) { + return review.file_status === 'pending' && !!review.async_request_id; +} + +// Reduces a model identifier to its bare name, ignoring the optional `provider:` prefix. +// A completed file review stores the bare model id (e.g. `gemini-3.1-flash-lite`), while the +// configured strategy stores the provider-qualified id (e.g. `google:gemini-3.1-flash-lite`). +// Comparing the bare form on both sides is what lets a retry recognise an already-reviewed file +// as inheritable -- without it, no completed review ever matches the config and every retry +// re-reviews every file from scratch. +function bareModelId(model: string): string { + const normalized = normalizeModelId(model); + const colon = normalized.indexOf(':'); + return colon === -1 ? normalized : normalized.slice(colon + 1); +} + function configuredModelSet(config: RepoConfig) { const models = new Set(); const addModel = (model: string | null | undefined) => { - if (model) models.add(normalizeModelId(model)); + if (model) models.add(bareModelId(model)); }; addModel(config.model?.main); @@ -90,7 +231,7 @@ function configuredModelSet(config: RepoConfig) { } function canInheritParentFileReview(config: RepoConfig, review: { model_used: string }) { - return configuredModelSet(config).has(normalizeModelId(review.model_used)); + return configuredModelSet(config).has(bareModelId(review.model_used)); } async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { @@ -191,6 +332,22 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): return { action: 'ack' }; } + // Concurrency admission control: only throttle a job that has NOT started yet (status 'queued'). + // A job that is already 'running' is mid-flight; re-gating its phase continuations would mean that + // lowering the concurrency limit (or any transient over-count from the check-then-claim race) + // makes every in-flight job retry forever -- that path returns before markJobContinuationQueued, + // so MAX_JOB_CONTINUATIONS never trips, the lease goes stale, and recovery force-fails the job. + // Gating only admission also avoids a second getReviewSettings fetch on review/finalize invocations. + if (resolved.job.status === 'queued') { + const { concurrencyLevel } = await getReviewSettings(env); + const maxConcurrentJobs = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; + const runningCount = await getOtherRunningJobsCount(env, resolved.job.id); + if (runningCount >= maxConcurrentJobs) { + logger.info(`Throttling admission of job ${resolved.job.id}: ${runningCount} other jobs are currently running.`); + return { action: 'retry', delaySeconds: 30 }; + } + } + const leaseOwner = crypto.randomUUID(); const claim = await claimJobLease(env, resolved.job.id, leaseOwner, JOB_LEASE_SECONDS); if (claim.status === 'missing') { @@ -207,6 +364,19 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): } const job = mapJob(claim.row); + + // Bind this job row to the ACTUAL Workflow instance id so job control (stop/delete/rerun) can + // terminate the right instance. The bind-workflow-id step can't do this for webhook-triggered + // jobs -- their instance is keyed on deliveryId while the job row (created later, in prepare) has + // a different id -- so we (re)bind here now that the real job is resolved. Cheap and idempotent. + if (message.workflowInstanceId && job.workflowInstanceId !== message.workflowInstanceId) { + try { + await setJobWorkflowInstance(env, job.id, message.workflowInstanceId); + } catch (error) { + logger.warn(`Failed to bind workflow instance id for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + } + const phase = resolved.phase; const tracker = new TokenTracker(); const github = new GitHubService(env, job.installationId, tracker); @@ -219,7 +389,7 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): } else if (phase === 'finalize') { await runFinalizePhase(env, job, leaseOwner, github, formatter); } else { - await runReviewPhase(env, job, leaseOwner, github, model); + await runReviewPhase(env, job, leaseOwner, github, model, tracker); } await releaseJobLease(env, job.id, leaseOwner); @@ -232,6 +402,14 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): return { action: 'ack' }; } + if (error instanceof NextPhaseError) { + await releaseJobLease(env, job.id, leaseOwner); + // Finalize needs a fresh instance for a clean subrequest budget to post the review; other + // phase transitions (e.g. the per-chunk review yield) stay in this instance and rely on the + // normal step.sleep hibernation to reset the budget. + return { action: 'next_phase', phase: error.phase, delaySeconds: error.delaySeconds, jobId: job.id, freshInstance: error.phase === 'finalize' }; + } + 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}`, { @@ -239,9 +417,28 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): phase, delaySeconds, }); - await enqueueJobPhase(env, job.id, phase, delaySeconds); - await releaseJobLease(env, job.id, leaseOwner); - return { action: 'ack' }; + return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'transient model/provider failures'); + } + + // Running out of this invocation's subrequest budget is not a job failure: every phase is + // idempotent enough to resume on a fresh budget, so reschedule the same phase instead of + // terminally failing the whole review. Prepare and review skip already-persisted files; + // finalize re-derives its inputs and is guarded against double-posting the GitHub review + // (see the findBotReviewForCommit guard in runFinalizePhase), so a finalize that exhausts the + // budget mid-way -- which the large-PR degrade path genuinely can -- resumes rather than dying. + if (isSubrequestBudgetError(error)) { + // A fresh Worker invocation is what fixes budget exhaustion -- but only a long-enough sleep + // actually hibernates the workflow into one. Yield long enough to force that hibernation. + const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null; + const delaySeconds = typeof record?.retryAfterSeconds === 'number' + ? record.retryAfterSeconds + : FRESH_INVOCATION_YIELD_SECONDS; + logger.warn(`Review job hit the per-invocation subrequest limit; rescheduling ${phase} on a fresh budget: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + error: messageText, + phase, + delaySeconds, + }); + return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'per-invocation subrequest limits'); } logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); @@ -251,6 +448,80 @@ export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): } } +// Records a same-phase continuation and enforces the MAX_JOB_CONTINUATIONS ceiling. As long as +// the job keeps completing files, resetJobContinuationCount() keeps this counter near zero; once +// it has rescheduled MAX_JOB_CONTINUATIONS times without a single file completing, the job is +// genuinely wedged (e.g. a provider is down for the entire backoff window), so we fail it +// terminally instead of letting it churn indefinitely. +async function continueOrFailWedgedJob( + env: AppBindings, + job: PersistedReviewJob, + github: GitHubService, + leaseOwner: string, + phase: 'prepare' | 'review' | 'finalize', + delaySeconds: number, + reason: string, +): Promise { + const continuationCount = await markJobContinuationQueued(env, job.id, delaySeconds); + + // Finalize burns its low ceiling fast so a saturated instance that can't post the review fails + // within a few minutes instead of looping ~20 min against the review-sized ceiling; every other + // phase keeps the generous ceiling because it makes real per-file progress. + const ceiling = phase === 'finalize' ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS; + + if (continuationCount > ceiling) { + if (phase === 'review') { + // Degrade to a partial review rather than throwing away the work done so far. NOTE: we must + // RETURN the finalize transition here, not call enqueueJobPhase() -- that helper throws + // NextPhaseError, and because continueOrFailWedgedJob runs inside runReviewJob's catch + // block that throw would escape the function uncaught instead of being turned into a result. + logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + phase, + continuationCount, + reason, + }); + // Any file still awaiting an async batch result would otherwise be finalized as an empty + // "successful" review (its 'pending' row isn't 'failed', so finalize maps it to verdict + // 'comment'/'' with no findings). Mark them failed first -- mirrors the async-poll degrade path. + const stillPending = (await getFileReviewsForJobs(env, [job.id])).filter(isAwaitingAsyncReview); + for (const review of stillPending) { + await persistFailedFileReview(env, job.id, { + filePath: review.file_path, + modelUsed: review.async_model ?? review.model_used, + diffLineCount: review.diff_line_count, + errorMessage: 'Async batch review did not complete before the job wedged.', + clearAsync: true, + }); + } + // Hand finalize its own fresh continuation budget. Without this the counter is already past + // MAX_JOB_CONTINUATIONS (that's what triggered this degrade), so the first time finalize hits + // a subrequest-budget limit it would re-enter continueOrFailWedgedJob already over the ceiling + // and fail terminally -- exactly the large-PR "Too many subrequests" failure this guards. + await resetJobContinuationCount(env, job.id); + await releaseJobLease(env, job.id, leaseOwner); + // Finalize on a fresh instance/budget -- this instance is the one that hit the wall. + return { action: 'next_phase', phase: 'finalize', delaySeconds: FRESH_INVOCATION_YIELD_SECONDS, jobId: job.id, freshInstance: true }; + } else { + const message = `Review could not make progress after ${continuationCount} continuation attempts (${reason}). Failing the job to avoid an endless retry loop; re-run it once the underlying provider issue clears.`; + logger.error(`Review job exceeded the continuation ceiling; failing terminally: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + phase, + continuationCount, + reason, + }); + await failJobAndCheckRun(env, job, github, message); + await releaseJobLease(env, job.id, leaseOwner); + return { action: 'ack' }; + } + } + + await releaseJobLease(env, job.id, leaseOwner); + // A subrequest-limit deferral means THIS instance is saturated and won't get a fresh budget by + // sleeping (a long-lived instance stops hibernating), so resume the phase in a brand-new instance. + // A transient model/provider deferral is not budget-related, so it stays in this instance. + const freshInstance = reason.includes('subrequest'); + return { action: 'next_phase', phase, delaySeconds, jobId: job.id, freshInstance }; // Resume same phase +} + async function resolveQueuedJob( env: AppBindings, message: ReviewJobMessage, @@ -393,6 +664,17 @@ async function runPreparePhase( const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + // Refresh the cached PR title/author from the live PR: these are snapshotted at job creation and + // copied onto retries, so a title edited on GitHub afterwards would otherwise stay stale. + try { + await setJobPullRequestMeta(env, job.id, { + prTitle: pr.title ?? null, + prAuthor: pr.user?.login ?? null, + }); + } catch (error) { + logger.warn(`Failed to refresh PR metadata for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + let checkRunId = job.checkRunId; if (!checkRunId) { const checkRun = await github.createCheckRun(job.owner, job.repo, { @@ -404,8 +686,7 @@ async function runPreparePhase( await updateJobCheckRun(env, job.id, checkRun.id); } - const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); - const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); + const files = await getDiffFiles(env, job, github, config); await completePreparationStep(env, job.id, files.length); await heartbeatJobLease(env, job.id, leaseOwner, JOB_LEASE_SECONDS); @@ -416,10 +697,16 @@ async function runPreparePhase( } if (checkRunId) { - await github.updateCheckRun(job.owner, job.repo, checkRunId, { - title: `Reviewing (0/${files.length})`, - summary: 'Codra is analyzing changed files.', - }); + // Best-effort progress cosmetics only (see runReviewPhase): don't let a failed check-run + // update block enqueuing the review phase that does the actual work. + try { + await github.updateCheckRun(job.owner, job.repo, checkRunId, { + title: `Reviewing (0/${files.length})`, + summary: 'Codra is analyzing changed files.', + }); + } catch (error) { + logger.warn(`Failed to update initial progress check run for job ${job.id}; continuing to the review phase anyway`, error instanceof Error ? error : new Error(String(error))); + } } await enqueueJobPhase(env, job.id, 'review'); } @@ -430,6 +717,7 @@ async function runReviewPhase( leaseOwner: string, github: GitHubService, model: ModelService, + tracker: TokenTracker, ) { if (!hasCompletedStep(job, 'Preparation')) { await runPreparePhase(env, job, leaseOwner, github); @@ -446,9 +734,18 @@ async function runReviewPhase( failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId); return failureModelProviderPromise; }; - const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); - const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); + const files = await getDiffFiles(env, job, github, config); const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0); + const { concurrencyLevel } = await getReviewSettings(env); + const configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; + // Cap this chunk's concurrency by the invocation's remaining subrequest budget so a run of + // model/provider failures can't push it over Cloudflare's per-invocation cap (Workers Free + // plan: 50) -- but sized (see budgetAwareFileLimit) so the configured concurrency level is + // honored in full at a healthy budget and only throttled once the budget is actually spent. + const reviewChunkFileLimit = budgetAwareFileLimit(tracker.remainingSafeBudget(), configuredChunkFileLimit); + if (reviewChunkFileLimit <= 0) { + throw new Error('Subrequest budget for this invocation was exhausted before starting the next review chunk.'); + } const startedAt = Date.now(); let processedThisChunk = 0; @@ -459,23 +756,122 @@ async function runReviewPhase( 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> = []; + // Counters shared across the concurrent review tasks below (single-threaded JS, so ++ is safe): + // `terminalProgress` counts files that reached a terminal state this chunk (reviewed, inherited, + // or permanently failed); `awaitingAsync` counts files still queued/running on the async batch. + let terminalProgress = 0; + let awaitingAsync = 0; + + // Fast path for retries: bulk-copy every reusable parent review in one cheap DB pass instead of + // re-persisting them one-per-budget-slot through the throttled loop below. Only files with no row + // yet in this job are bulk-inherited; anything with an existing row falls through to the loop + // (which handles its own inherit/re-review decision). This lets a fully-inheritable retry finish + // the whole review phase in a single invocation rather than crawling through ~12 hibernated chunks. + if (job.retryOfJobId && parentReviews.size > 0) { + const inheritablePaths = files + .filter((file) => { + if (currentReviews.has(file.path)) return false; + const parent = parentReviews.get(file.path); + return Boolean(parent && canInheritParentFileReview(config, parent)); + }) + .map((file) => file.path); + + if (inheritablePaths.length > 0) { + const inheritedPaths = await bulkInheritFileReviews(env, { + jobId: job.id, + parentJobId: job.retryOfJobId, + filePaths: inheritablePaths, + }); + // Mark the just-copied files handled so the loop below skips them (no per-file re-work). + for (const path of inheritedPaths) { + const parent = parentReviews.get(path); + if (parent) currentReviews.set(path, parent); + } + terminalProgress += inheritedPaths.length; + if (inheritedPaths.length > 0) { + logger.info(`Bulk-inherited ${inheritedPaths.length} parent file reviews for job ${job.id} in one pass`); + } + } + } for (const file of files) { const existingReview = currentReviews.get(file.path); - if (existingReview && countsAsHandledFileReview(existingReview)) { + // An in-flight async submission must be polled (not skipped as "handled" and not resubmitted). + const awaitingReview = existingReview && isAwaitingAsyncReview(existingReview) ? existingReview : null; + if (existingReview && countsAsHandledFileReview(existingReview) && !awaitingReview) { continue; } const inherited = parentReviews.get(file.path); const reviewTask = async () => { + // (0) Poll an already-submitted async batch review. + if (awaitingReview) { + const poll = await model.pollReviewBatch({ + model: awaitingReview.async_model ?? awaitingReview.model_used, + requestId: awaitingReview.async_request_id!, + file, + }); + if (poll.status === 'pending') { + awaitingAsync += 1; + return; + } + if (poll.status === 'failed') { + // The batch errored/expired -- fall back to a synchronous review so the file still gets done. + logger.warn(`Async batch poll failed for ${file.path}; falling back to synchronous review`, { + error: poll.error instanceof Error ? poll.error.message : String(poll.error), + }); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); + terminalProgress += 1; + return; + } + await persistCompletedReview(env, job, file, poll.response); + terminalProgress += 1; + return; + } + if (!inherited) { + // (1) Try the async batch queue first; on any unavailability fall back to sync review. + const submitted = await model.submitReviewBatch({ + file, + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + config, + totalLineCount, + compactPrompt: (existingReview?.transient_error_count ?? 0) > 0, + }); + if (submitted) { + await upsertFileReview(env, job.id, { + filePath: file.path, + fileStatus: 'pending', + modelUsed: submitted.model, + modelProvider: null, + diffLineCount: file.lineCount, + diffInput: null, + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: null, + verdict: null, + fileSummary: null, + overallCorrectness: null, + confidenceScore: null, + errorMessage: null, + asyncRequestId: submitted.requestId, + asyncModel: submitted.model, + }); + awaitingAsync += 1; + return; + } await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); + terminalProgress += 1; return; } if (!canInheritParentFileReview(config, inherited)) { logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); + terminalProgress += 1; } else { await upsertFileReview(env, job.id, { filePath: file.path, @@ -496,13 +892,14 @@ async function runReviewPhase( errorMessage: null, }); currentReviews.set(file.path, inherited); + terminalProgress += 1; } }; reviewTasks.push(reviewTask()); processedThisChunk += 1; - if (processedThisChunk >= REVIEW_CHUNK_FILE_LIMIT || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { + if (processedThisChunk >= reviewChunkFileLimit || Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { break; } } @@ -510,33 +907,179 @@ async function runReviewPhase( const results = await Promise.allSettled(reviewTasks); await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + // Terminal progress means a file reached a terminal state this chunk (reviewed, inherited, or + // marked permanently failed). Clear the no-progress continuation counter so a slow-but-advancing + // job never trips the MAX_JOB_CONTINUATIONS safety net. A chunk that only *submitted* or *polled* + // still-pending async batches made no terminal progress, so it must NOT reset the counter -- + // that's what bounds polling of a batch that never completes. + if (terminalProgress > 0) { + await resetJobContinuationCount(env, job.id); + } + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); if (rejected.length > 0) { rejected.forEach((result, index) => { logger.error(`Review chunk task ${index + 1}/${rejected.length} failed`, result.reason); }); + + // If any rejected task was a transient model error or a per-invocation subrequest-budget + // hit, surface that single error so the job orchestrator reschedules the chunk on a fresh + // budget, instead of failing the job with an AggregateError. + const deferrableError = rejected.map(r => r.reason).find(r => isRetryableModelError(r) || isSubrequestBudgetError(r)); + if (deferrableError) { + throw deferrableError; + } + throw rejected.length === 1 ? rejected[0].reason : new AggregateError(rejected.map((result) => result.reason), `${rejected.length} review chunk tasks failed`); } const latestReviews = await getFileReviewsForJobs(env, [job.id]); - const reviewedPaths = new Set(latestReviews.filter(countsAsHandledFileReview).map((review) => review.file_path)); + // A file still awaiting its async batch result is NOT complete yet -- exclude it so the job + // doesn't finalize with pending reviews. + const reviewedPaths = new Set( + latestReviews.filter((review) => countsAsHandledFileReview(review) && !isAwaitingAsyncReview(review)).map((review) => review.file_path), + ); const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length; if (completedCount >= files.length) { await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); - await enqueueJobPhase(env, job.id, 'finalize'); + // Finalize (post the GitHub review, labels, check run) needs its OWN fresh subrequest budget. + // Always hibernate into a new invocation first: the review phase that just finished spent this + // invocation's budget, and TokenTracker under-reports real usage (it doesn't see Hyperdrive/ + // GitHub subrequests), so a conditional yield let finalize run in the exhausted invocation and + // die with "Too many subrequests". Unconditional yield trades a one-time delay for reliability. + await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); return; } + // If the only thing left is in-flight async batches (no synchronous work remains to advance), + // poll again after a short delay rather than immediately re-running. Bound the polling with the + // shared continuation ceiling: markJobContinuationQueued returns the post-increment count, and + // because a pending-only chunk never reset it (terminalProgress === 0), a batch that never + // completes will eventually cross MAX_JOB_CONTINUATIONS and degrade to a partial review instead + // of polling forever. + if (awaitingAsync > 0 && terminalProgress === 0) { + const pollCount = await markJobContinuationQueued(env, job.id, ASYNC_BATCH_POLL_DELAY_SECONDS); + if (pollCount > MAX_JOB_CONTINUATIONS) { + logger.error(`Async batch reviews did not complete after ${pollCount} polls; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`); + for (const review of latestReviews.filter(isAwaitingAsyncReview)) { + await persistFailedFileReview(env, job.id, { + filePath: review.file_path, + modelUsed: review.async_model ?? review.model_used, + diffLineCount: review.diff_line_count, + errorMessage: 'Async batch review did not complete in time.', + clearAsync: true, + }); + } + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + throw new NextPhaseError('finalize', FRESH_INVOCATION_YIELD_SECONDS); + } + throw new NextPhaseError('review', ASYNC_BATCH_POLL_DELAY_SECONDS); + } + if (job.checkRunId) { - await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { - title: `Reviewing (${completedCount}/${files.length})`, - summary: 'Codra is continuing this review in the next queue chunk.', - }); + // Best-effort progress cosmetics only: the file reviews for this chunk are already + // persisted, so a failure here (e.g. this invocation's subrequest budget is spent) must + // not stop us from enqueuing the next chunk that finishes the job. + try { + await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { + title: `Reviewing (${completedCount}/${files.length})`, + summary: 'Codra is continuing this review in the next queue chunk.', + }); + } catch (error) { + logger.warn(`Failed to update progress check run for job ${job.id}; continuing to the next chunk anyway`, error instanceof Error ? error : new Error(String(error))); + } } - await enqueueJobPhase(env, job.id, 'review'); + // More files remain -- the next chunk needs a fresh subrequest budget, so yield long enough to + // force the workflow to hibernate into a new invocation rather than looping in this one (which + // would accumulate subrequests across chunks until the cap is hit). + await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); +} + +/** + * Persist a completed review produced by the async batch poll path. Mirrors the success branch of + * reviewAndPersistFile and clears the async bookkeeping columns so the row is terminal ('done'). + */ +async function persistCompletedReview( + env: AppBindings, + job: PersistedReviewJob, + file: ReturnType[number], + response: { + modelUsed: string; + provider: string; + inputTokens: number; + outputTokens: number; + rawText: string; + userPrompt: string; + parsed: { + comments: ParsedReviewComment[]; + verdict: 'approve' | 'comment'; + fileSummary: string; + overallCorrectness?: string; + confidenceScore?: number; + }; + }, +) { + 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: null, + verdict: response.parsed.verdict, + fileSummary: response.parsed.fileSummary, + overallCorrectness: response.parsed.overallCorrectness, + confidenceScore: response.parsed.confidenceScore, + errorMessage: null, + asyncRequestId: null, + asyncModel: null, + }); +} + +/** + * Persist a file review as terminally 'failed' with the shared "mostly-null" shape. Collapses the + * several near-identical failure upserts (transient-retry exhaustion, hard provider limit, async + * batch giving up, finalize backfilling missing files) into one place. `clearAsync` wipes the + * async batch bookkeeping columns for rows that had been submitted to the queue. + */ +async function persistFailedFileReview( + env: AppBindings, + jobId: string, + input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + durationMs?: number | null; + errorMessage: string; + clearAsync?: boolean; + }, +) { + await upsertFileReview(env, jobId, { + filePath: input.filePath, + fileStatus: 'failed', + modelUsed: input.modelUsed, + modelProvider: input.modelProvider ?? null, + diffLineCount: input.diffLineCount, + diffInput: '', + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: input.durationMs ?? null, + verdict: null, + fileSummary: null, + errorMessage: input.errorMessage, + ...(input.clearAsync ? { asyncRequestId: null, asyncModel: null } : {}), + }); } async function reviewAndPersistFile( @@ -585,6 +1128,22 @@ async function reviewAndPersistFile( const modelId = config.model?.main ?? 'unconfigured'; const modelProvider = await resolveFailureModelProvider(); + // Per-invocation subrequest pressure clears on the next Worker invocation, so do not count + // it as a per-file provider outage. Let the job-level no-progress continuation ceiling bound + // a genuinely wedged job while this file remains pending for the fresh-budget retry. + if (isSubrequestBudgetError(error)) { + logger.warn(`File review deferred for ${file.path}; subrequest budget will retry in a fresh invocation`, { + error: errorMessage, + }); + Object.defineProperty(error, 'retryAfterSeconds', { + value: FRESH_INVOCATION_YIELD_SECONDS, + configurable: true, + }); + throw error; + } + + // Transient model/provider outages count against the file so a single unrecoverable file + // eventually becomes a partial-review failure instead of blocking the entire job forever. if (isRetryableModelError(error)) { const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, @@ -598,20 +1157,12 @@ async function reviewAndPersistFile( if (failureCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES) { const finalError = `Review skipped after ${failureCount} repeated model provider outages.`; - await upsertFileReview(env, job.id, { + await persistFailedFileReview(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: finalError, }); logger.error(`File review failed permanently for ${file.path} after transient retries`, { @@ -634,34 +1185,63 @@ async function reviewAndPersistFile( logger.error(`File review failed for ${file.path}`, { error }); + // A genuine provider allocation exhaustion (e.g. Cloudflare Workers AI daily free + // allocation, error 4006) won't clear by retrying within this job, so mark the file failed + // and let the PR review complete as a partial review. (Per-invocation subrequest limits are + // NOT hard limits -- they're handled as deferrals above and retried on a fresh budget.) const isHardLimit = - errorMessage.toLowerCase().includes('subrequest') || errorMessage.includes('4006') || errorMessage.toLowerCase().includes('allocation'); if (isHardLimit) { - throw error; + logger.warn(`File review hit hard provider allocation limit for ${file.path}, marking as failed to allow partial PR review.`, { error: errorMessage }); + // We don't throw here; we just fall through and let it be marked as failed + // so the PR review can continue and complete as a partial review. } - await upsertFileReview(env, job.id, { + await persistFailedFileReview(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, }); } } +/** + * Assemble and fire the anonymous per-review telemetry event. The shared aggregate fields + * (line/token counts, models, file extensions, duration) are computed once here; the three fields + * that differ between the success and all-failed paths are passed as `overrides`. Never throws. + */ +async function sendReviewTelemetry( + env: AppBindings, + job: PersistedReviewJob, + files: Array<{ path: string; lineCount: number }>, + reviews: Array<{ input_tokens: number | null; output_tokens: number | null; model_used: string }>, + overrides: { findingsReported: number; verdict: string; severityDistribution: Record }, +) { + try { + await sendTelemetryEvent(env, { + linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0), + inputTokens: reviews.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0), + outputTokens: reviews.reduce((sum, r) => sum + (r.output_tokens ?? 0), 0), + modelsUsed: Array.from(new Set(reviews.map((r) => r.model_used).filter(Boolean))), + fileExtensions: Array.from(new Set(files.map((f) => { + const parts = f.path.split('.'); + return parts.length > 1 ? parts.pop() || '' : ''; + }).filter(Boolean))), + triggerType: job.trigger, + reviewDurationMs: Math.max(0, Date.now() - new Date(job.createdAt).getTime()), + filesReviewed: files.length, + ...overrides, + }); + } catch (e) { + logger.error('Failed to send telemetry', e instanceof Error ? e : new Error(String(e))); + } +} + async function runFinalizePhase( env: AppBindings, job: PersistedReviewJob, @@ -673,16 +1253,43 @@ async function runFinalizePhase( const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; - const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); - const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); - const reviews = await getFileReviewsForJobs(env, [job.id]); + const files = await getDiffFiles(env, job, github, config); + let 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 reviewedPaths = new Set(reviews.map((r) => r.file_path)); + const missingFiles = files.filter((f) => !reviewedPaths.has(f.path)); + + if (missingFiles.length > 0) { + logger.warn(`Job ${job.id} reached finalize phase with ${missingFiles.length} missing file reviews. Forcing them to failed state.`); + // Batch the backfill into one INSERT. Doing it per-file (a transaction each) scales the + // subrequest cost with the number of missing files and, on a large/growing PR, exhausts the + // per-invocation budget right before the review is posted (finalize can't safely hibernate -- + // it posts the GitHub review -- so it must stay within one invocation's budget). + await bulkMarkFilesFailed( + env, + job.id, + missingFiles.map((file) => ({ filePath: file.path, diffLineCount: file.lineCount })), + { modelUsed: config.model?.main ?? 'unconfigured', errorMessage: 'This file was not reviewed before the review run completed.' }, + ); + + // Refresh reviews list after inserting the missing ones + reviews = await getFileReviewsForJobs(env, [job.id]); + } else { + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); + // Bounce back to review on a fresh invocation/budget (finalize already spent this one). + await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); + return; + } } + // Finalize is now committed to finishing, so the review phase is over. Defensively mark + // "Reviewing Files" done: some paths into finalize (notably the continuation-ceiling degrade in + // continueOrFailWedgedJob) don't set it, which otherwise leaves the step stuck showing + // "In progress" on a job that's actually done. updateJobStep keeps the first finish time, so this + // no-ops the timestamp when the review phase already marked it done. + await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + const reviewedComments = reviews.flatMap((review) => review.parsed_comments as ParsedReviewComment[]); const fileSummaries = reviews.map((review) => ({ path: review.file_path, @@ -694,6 +1301,13 @@ async function runFinalizePhase( 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' }); + + await sendReviewTelemetry(env, job, files, reviews, { + findingsReported: 0, + verdict: 'failed', + severityDistribution: {}, + }); + throw new Error('All files failed to review'); } @@ -701,13 +1315,15 @@ async function runFinalizePhase( const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length; const severityRanks: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; const minRank = severityRanks[config.review.min_severity] ?? 4; - + const { maxComments: globalMaxComments } = await getReviewSettings(env); + const effectiveMaxComments = Math.min(config.review.max_comments, globalMaxComments); + let finalComments = reviewedComments.filter(c => (severityRanks[c.severity] ?? 4) <= minRank); finalComments.sort((a, b) => (severityRanks[a.severity] ?? 4) - (severityRanks[b.severity] ?? 4)); - - const omittedCount = reviewedComments.length - Math.min(finalComments.length, config.review.max_comments); - if (finalComments.length > config.review.max_comments) { - finalComments = finalComments.slice(0, config.review.max_comments); + + const omittedCount = reviewedComments.length - Math.min(finalComments.length, effectiveMaxComments); + if (finalComments.length > effectiveMaxComments) { + finalComments = finalComments.slice(0, effectiveMaxComments); } const verdictSummary = formatter.summarizeVerdict(finalComments, hasFailures); @@ -715,13 +1331,23 @@ async function runFinalizePhase( await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME); - + if (omittedCount > 0) { - formattedSummary += `\n\n> [!NOTE]\n> **${omittedCount} comments were omitted** from this review to reduce noise and respect the configured \`max_comments\` limit (${config.review.max_comments}). Showing the most critical issues.`; + formattedSummary += `\n\n> [!NOTE]\n> **${omittedCount} comments were omitted** from this review to reduce noise and respect the configured \`max_comments\` limit (${effectiveMaxComments}). Showing the most critical issues.`; } + // If a prior finalize attempt already reached the posting stage (the 'Completing' step was + // started) and then died before completeJob recorded the review id, the review may already be on + // GitHub. Re-posting would duplicate it, so reuse the existing one. This GitHub read is only paid + // on an actual finalize re-run, never on the common first pass. + const finalizeRetriedPastPost = job.steps.some( + (step) => step.name === 'Completing' && (step.status === 'running' || step.status === 'done'), + ); await updateJobStep(env, job.id, 'Completing', { status: 'running' }); - const review = await github.createReview(job.owner, job.repo, job.prNumber, { + const existingReview = finalizeRetriedPastPost + ? await github.findBotReviewForCommit(job.owner, job.repo, job.prNumber, pr.head.sha, env.BOT_USERNAME) + : null; + const review = existingReview ?? await github.createReview(job.owner, job.repo, job.prNumber, { commitSha: pr.head.sha, event: formatter.toReviewEvent(verdictSummary.verdict), body: formattedSummary, @@ -732,39 +1358,22 @@ async function runFinalizePhase( })), }); - 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]); - } + const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0); + const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0); - 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: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed after repeated provider outages.` : ''}`, - }); + const severityDistribution: Record = {}; + for (const comment of finalComments) { + const sev = comment.severity || 'unknown'; + severityDistribution[sev] = (severityDistribution[sev] || 0) + 1; } - 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.` + ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed.` : null; + // Record the posted review and mark the job done IMMEDIATELY after createReview -- before the + // best-effort cosmetics below. The review is already on GitHub at this point; if the remaining + // label/check-run calls exhaust this invocation's subrequest budget (large PR), we must not leave + // the job stranded as 'failed' with review_id null. This is the critical, must-not-lose write. await completeJob(env, job.id, { verdict: verdictSummary.verdict, fileCount: files.length, @@ -777,6 +1386,61 @@ async function runFinalizePhase( errorMessage: partialErrorMessage, }); logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); + + // The cached PR diff is only needed while the job is being reviewed. Drop it now the job is done + // so completed jobs don't leave large diff blobs sitting in KV until the 6h TTL expires. + try { + await env.APP_KV.delete(diffCacheKey(job.id)); + } catch (error) { + logger.warn(`Failed to delete cached diff for completed job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + + // Cosmetics: labels and the check-run conclusion. Best-effort -- the review is posted and the job + // is already 'done', so a failure here (e.g. subrequest budget spent, GitHub blip) must not fail + // the job. completeTerminalCheckRuns / a re-run can reconcile a check run left un-updated. + try { + // Check-run conclusion first: it drives the PR's status badge, so it matters more than labels + // if the budget only allows one of them. + 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: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed.` : ''}`, + }); + // Only now is the check run genuinely completed -- record it so the maintenance sweep doesn't + // redo it. If the update above threw, this line is skipped and completeTerminalCheckRuns will + // finish the check run on a later invocation with a fresh budget. + await markJobCheckRunCompleted(env, job.id); + } + + 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]); + } + } catch (error) { + logger.warn(`Post-review labels/check-run update failed for job ${job.id}; review is posted and job is completed, so leaving it best-effort`, error instanceof Error ? error : new Error(String(error))); + } + + await sendReviewTelemetry(env, job, files, reviews, { + findingsReported: finalComments.length, + verdict: verdictSummary.verdict, + severityDistribution, + }); } async function heartbeatAndCheckSuperseded(env: AppBindings, jobId: string, leaseOwner: string) { @@ -787,6 +1451,12 @@ async function heartbeatAndCheckSuperseded(env: AppBindings, jobId: string, leas } } +export class NextPhaseError extends Error { + constructor(public phase: 'prepare' | 'review' | 'finalize', public delaySeconds: number) { + super(`NextPhase: ${phase}`); + } +} + async function enqueueJobPhase( env: AppBindings, jobId: string, @@ -794,28 +1464,63 @@ async function enqueueJobPhase( delaySeconds = 0, ) { await markJobContinuationQueued(env, jobId, delaySeconds); - await env.REVIEW_QUEUE.send( - { - jobId, - deliveryId: crypto.randomUUID(), - phase, - }, - delaySeconds > 0 ? { delaySeconds } : undefined, - ); + throw new NextPhaseError(phase, delaySeconds); } function hasCompletedStep(job: PersistedReviewJob, stepName: string) { return job.steps.some((step) => step.name === stepName && step.status === 'done'); } -async function failJobAndCheckRun( +function diffCacheKey(jobId: string) { + return `diff:${jobId}`; +} + +/** + * Returns the job's reviewable files, fetching and parsing the PR diff from + * GitHub only once per job (cached in KV) instead of once per phase invocation. + */ +export async function getDiffFiles( env: AppBindings, - job: PersistedReviewJob, - github: GitHubService, + job: Pick, + github: Pick, + config: RepoConfig, +) { + const cacheKey = diffCacheKey(job.id); + let rawDiff = await env.APP_KV.get(cacheKey); + + if (!rawDiff) { + rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); + try { + await env.APP_KV.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); + } catch (error) { + logger.warn(`Failed to cache PR diff for job ${job.id}; it will be re-fetched on the next phase`, error instanceof Error ? error : new Error(String(error))); + } + } + + return filterReviewableFiles(parseUnifiedDiff(rawDiff, config.review), config.review); +} + +export async function failJobAndCheckRun( + env: AppBindings, + job: Pick, + github: Pick, message: string, ) { + // Marking the job failed in the DB is the critical, must-not-lose write: it's what + // makes the job terminal so it stops being retried, and it's what makes it eligible + // for completeTerminalCheckRuns() to pick up later if the GitHub call below fails. try { await failJob(env, job.id, message); + } catch (dbError) { + logger.error(`Critical: failed to mark job ${job.id} as failed in the DB; it may remain stuck until lease-expiry recovery reclaims it`, dbError); + return; + } + + // Updating the GitHub check run is best-effort here. If it fails (e.g. the Worker's + // subrequest budget for this invocation is already exhausted from the review itself), + // the job is still durably marked failed above, and the opportunistic maintenance sweep + // (completeTerminalCheckRuns) will retry this update on a later invocation with a fresh budget. + try { const latest = await getJobForProcessing(env, job.id); const checkRunId = latest?.check_run_id ?? job.checkRunId; if (checkRunId) { @@ -827,7 +1532,7 @@ async function failJobAndCheckRun( }); await markJobCheckRunCompleted(env, job.id); } - } catch (innerError) { - logger.error('Failed to record job failure in DB/GitHub', innerError); + } catch (checkRunError) { + logger.warn(`Failed to update GitHub check run for failed job ${job.id}; opportunistic maintenance will retry it`, checkRunError); } } diff --git a/src/server/core/telemetry.ts b/src/server/core/telemetry.ts new file mode 100644 index 0000000..28be312 --- /dev/null +++ b/src/server/core/telemetry.ts @@ -0,0 +1,114 @@ +import type { AppBindings } from '@server/env'; +import { logger } from './logger'; + +const TELEMETRY_SECRET = 'codra-telemetry-v1-secret-8f9a2b5c'; +const INSTANCE_ID_KEY = 'codra:instance_id'; + +/** + * Returns a stable, anonymous instance ID. + * Generates and stores one in KV if it doesn't exist yet. + */ +import { queryRows } from '@server/db/client'; + +export async function getInstanceId(env: AppBindings): Promise { + try { + const rows = await queryRows<{ value: string }>(env, 'SELECT value FROM global_settings WHERE key = $1', [INSTANCE_ID_KEY]); + let instanceId = rows[0]?.value; + + if (!instanceId) { + instanceId = crypto.randomUUID(); + await queryRows( + env, + 'INSERT INTO global_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING', + [INSTANCE_ID_KEY, instanceId] + ); + // Fetch again in case another instance inserted it concurrently + const rowsAfter = await queryRows<{ value: string }>(env, 'SELECT value FROM global_settings WHERE key = $1', [INSTANCE_ID_KEY]); + instanceId = rowsAfter[0]?.value ?? instanceId; + } + return instanceId; + } catch (error) { + logger.warn('Failed to retrieve or generate instance ID for telemetry', { + error: error instanceof Error ? error.message : String(error), + }); + // Fallback to a random UUID so telemetry can still send, though it will + // count as a new "install" if the DB is failing. + return crypto.randomUUID(); + } +} + +/** + * Sends an anonymous telemetry event to Codra Core backend. + * Swallows all errors so the caller is never interrupted. + */ +export async function sendTelemetryEvent( + env: AppBindings, + data: { + linesReviewed: number; + findingsReported: number; + inputTokens: number; + outputTokens: number; + modelsUsed: string[]; + fileExtensions: string[]; + triggerType: string; + reviewDurationMs: number; + filesReviewed: number; + verdict?: string; + severityDistribution: Record; + }, +): Promise { + try { + // Opt-out for self-hosters/forks: set TELEMETRY_DISABLED=true (or 1) to send nothing. + const disabled = String((env as any).TELEMETRY_DISABLED ?? '').toLowerCase(); + if (disabled === 'true' || disabled === '1') { + return; + } + + const instanceId = await getInstanceId(env); + // Use an environment variable if available, otherwise default to the hosted backend + const telemetryUrl = (env as any).TELEMETRY_API_URL ?? 'https://codra.run/api/telemetry'; + // Allow the ingestion secret to be overridden via env so it isn't pinned to the value committed + // in this (public) source tree. + const telemetrySecret = (env as any).TELEMETRY_SECRET ?? TELEMETRY_SECRET; + + // Fire and forget using standard fetch with a timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + await fetch(telemetryUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${telemetrySecret}`, + }, + body: JSON.stringify({ + instanceId, + prsReviewed: 1, + linesReviewed: data.linesReviewed, + findingsReported: data.findingsReported, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + modelsUsed: data.modelsUsed, + fileExtensions: data.fileExtensions, + triggerType: data.triggerType, + reviewDurationMs: data.reviewDurationMs, + filesReviewed: data.filesReviewed, + verdict: data.verdict, + severityDistribution: data.severityDistribution, + }), + signal: controller.signal, + }).catch((error) => { + // Intentionally swallowed: Network errors are expected occasionally + logger.debug('Failed to send anonymous telemetry event (network)', { + error: error instanceof Error ? error.message : String(error), + }); + }).finally(() => { + clearTimeout(timeoutId); + }); + } catch (error) { + // Intentionally swallowed: We never want telemetry to fail a PR review + logger.debug('Failed to send anonymous telemetry event (setup)', { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/server/core/token-tracker.ts b/src/server/core/token-tracker.ts index c7951f1..74a6397 100644 --- a/src/server/core/token-tracker.ts +++ b/src/server/core/token-tracker.ts @@ -14,7 +14,10 @@ export class TokenTracker { private usage: Map = new Map(); private subrequests = 0; private readonly MAX_SUBREQUESTS = 50; - private readonly SAFE_MARGIN = 22; // Increased even further to handle finalization overhead + // Increased from 10 to 25 to account for the untracked Hyperdrive queries that happen per chunk + // (e.g. lease heartbeats, getting reviews, upserting reviews, etc.) which can easily add ~15 + // subrequests that the tracker doesn't see. + private readonly SAFE_MARGIN = 25; incrementSubrequests(count = 1) { this.subrequests += count; @@ -32,6 +35,18 @@ export class TokenTracker { return this.subrequests >= this.MAX_SUBREQUESTS - this.SAFE_MARGIN; } + /** + * How many more subrequests can safely be spent right now before crossing into the + * reserved safety margin below Cloudflare's hard per-invocation cap (Workers Free plan: + * 50 subrequests/invocation). Callers that can start a variable amount of concurrent work + * (e.g. how many files to review at once) should size that work against this number + * instead of a fixed constant, so throughput stays high while the budget is healthy and + * automatically shrinks as it's spent. + */ + remainingSafeBudget() { + return Math.max(0, this.MAX_SUBREQUESTS - this.SAFE_MARGIN - this.subrequests); + } + /** * Records token usage for a specific model call. */ diff --git a/src/server/db/app-settings.ts b/src/server/db/app-settings.ts new file mode 100644 index 0000000..5c496e6 --- /dev/null +++ b/src/server/db/app-settings.ts @@ -0,0 +1,48 @@ +import type { AppBindings } from '@server/env'; +import { queryRows } from './client'; +import { logger } from '@server/core/logger'; +import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewSettingsSchema, type ReviewSettings } from '@shared/schema'; + +const CONCURRENCY_KEY = 'review_concurrency_level'; +const MAX_COMMENTS_KEY = 'review_max_comments'; + +const DEFAULT_REVIEW_SETTINGS: ReviewSettings = reviewSettingsSchema.parse({}); +const CONCURRENCY_LEVELS = new Set(reviewConcurrencyLevels); +const MAX_COMMENTS_OPTIONS = new Set(reviewMaxCommentsOptions); + +export async function getReviewSettings(env: Pick): Promise { + try { + const rows = await queryRows<{ key: string; value: string }>( + env, + 'SELECT key, value FROM global_settings WHERE key = ANY($1)', + [[CONCURRENCY_KEY, MAX_COMMENTS_KEY]], + ); + const map = new Map(rows.map((row) => [row.key, row.value])); + const storedConcurrency = map.get(CONCURRENCY_KEY); + const storedMaxComments = map.get(MAX_COMMENTS_KEY); + const parsedMaxComments = storedMaxComments === undefined ? NaN : Number(storedMaxComments); + + return reviewSettingsSchema.parse({ + concurrencyLevel: storedConcurrency && CONCURRENCY_LEVELS.has(storedConcurrency) + ? storedConcurrency + : DEFAULT_REVIEW_SETTINGS.concurrencyLevel, + maxComments: MAX_COMMENTS_OPTIONS.has(parsedMaxComments) + ? parsedMaxComments + : DEFAULT_REVIEW_SETTINGS.maxComments, + }); + } catch (error) { + logger.warn('Failed to load review settings, using defaults', { + error: error instanceof Error ? error.message : String(error), + }); + return DEFAULT_REVIEW_SETTINGS; + } +} + +export async function updateReviewSettings(env: Pick, settings: ReviewSettings): Promise { + await queryRows( + env, + `INSERT INTO global_settings (key, value) VALUES ($1, $2), ($3, $4) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [CONCURRENCY_KEY, settings.concurrencyLevel, MAX_COMMENTS_KEY, String(settings.maxComments)], + ); +} diff --git a/src/server/db/client.ts b/src/server/db/client.ts index 7a41254..ab4341e 100644 --- a/src/server/db/client.ts +++ b/src/server/db/client.ts @@ -62,8 +62,29 @@ export function runWithDb(env: DbEnv, fn: () => T): T { return dbStorage.run(createDbClient(env), fn); } +// Reused clients for the no-runWithDb() fallback path below. Keyed by connection string so a caller +// that queries outside a runWithDb() scope shares one bounded pool instead of leaking a fresh pool +// (up to `max` connections) on every single query. +const fallbackClients = new Map(); + export function getDb(env: DbEnv) { - return dbStorage.getStore() ?? createDbClient(env); + const store = dbStorage.getStore(); + if (store) return store; + + // Outside a runWithDb() scope. In production this never happens -- fetch, queue, scheduled, and + // the workflow all wrap their work in runWithDb() -- so this branch is effectively test-only (the + // Hono test client calls app.fetch() directly, and tests call db helpers directly). Creating a new + // pool per query there leaked connections across the whole test process and exhausted CI's Postgres + // (masked locally by Neon's pooler). Reuse one client per connection string instead. Keeping this + // strictly to the store-less path preserves the per-request client production relies on to satisfy + // Cloudflare's "no I/O across request contexts" rule. + const connectionString = env.HYPERDRIVE.connectionString; + let client = fallbackClients.get(connectionString); + if (!client) { + client = createDbClient(env); + fallbackClients.set(connectionString, client); + } + return client; } export async function queryRows(env: DbEnv, sqlText: string, params: unknown[] = []) { diff --git a/src/server/db/file-reviews.ts b/src/server/db/file-reviews.ts index 641d4f9..1877d4f 100644 --- a/src/server/db/file-reviews.ts +++ b/src/server/db/file-reviews.ts @@ -111,6 +111,10 @@ export async function upsertFileReview( overallCorrectness?: string | null; confidenceScore?: number | null; errorMessage: string | null; + // Async batch bookkeeping: set when a review is submitted to the Workers AI queue (status + // 'pending'), cleared (null) once the batch completes and a terminal review is persisted. + asyncRequestId?: string | null; + asyncModel?: string | null; }, ) { await queryTransaction(env, async (tx) => { @@ -132,9 +136,11 @@ export async function upsertFileReview( overall_correctness, confidence_score, error_msg, - model_provider + model_provider, + async_request_id, + async_model ) - VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ON CONFLICT (job_id, file_path) DO UPDATE SET file_status = EXCLUDED.file_status, model_used = EXCLUDED.model_used, @@ -150,6 +156,8 @@ export async function upsertFileReview( confidence_score = EXCLUDED.confidence_score, error_msg = EXCLUDED.error_msg, model_provider = EXCLUDED.model_provider, + async_request_id = EXCLUDED.async_request_id, + async_model = EXCLUDED.async_model, transient_error_count = 0 RETURNING id `, @@ -170,6 +178,8 @@ export async function upsertFileReview( input.confidenceScore ?? null, input.errorMessage, input.modelProvider ?? null, + input.asyncRequestId ?? null, + input.asyncModel ?? null, ], ); @@ -270,151 +280,110 @@ export async function recordRetryableFileReviewFailure( }); } -export async function getModelUsageStats(env: Pick) { - return queryRows<{ - model_used: string; - model_provider: string | null; - calls: number; - input_tokens: number | null; - output_tokens: number | null; - }>( - env, - ` - SELECT - model_used, - MIN(model_provider) AS model_provider, - COUNT(*)::int AS calls, - COALESCE(SUM(input_tokens), 0)::int AS input_tokens, - COALESCE(SUM(output_tokens), 0)::int AS output_tokens - FROM file_reviews - GROUP BY model_used - ORDER BY calls DESC, model_used ASC - `, - ); -} - -export async function batchInsertFileReviews( +/** + * Copy every still-needed, inheritable parent review (and its comments) into `jobId` in a single + * cheap DB pass. A retry that can reuse the parent's completed reviews would otherwise re-persist + * them one file at a time through the budget-limited review loop (~5 files/chunk, hibernating a + * fresh invocation between chunks), turning a fully-inheritable retry into a many-minute crawl. + * This collapses all of them into one transaction (a couple of subrequests total, regardless of + * file count) so the review phase finishes in a single invocation. `filePaths` must already be + * filtered to files that are inheritable under the current model strategy and have no row yet in + * the target job. Returns the file paths that were actually inserted. + */ +export async function bulkInheritFileReviews( env: Pick, - jobId: string, - reviews: Array<{ - 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; - }>, -) { - if (reviews.length === 0) return; + input: { jobId: string; parentJobId: string; filePaths: string[] }, +): Promise { + if (input.filePaths.length === 0) return []; - // 1. Insert file reviews and get their IDs - // Since we want to handle comments too, we'll do this in a single query with UNNEST and RETURNING - const filePaths = reviews.map(r => r.filePath); - const fileStatuses = reviews.map(r => r.fileStatus); - const modelsUsed = reviews.map(r => r.modelUsed); - const diffLineCounts = reviews.map(r => r.diffLineCount); - const diffInputs = reviews.map(r => r.diffInput); - const rawAiOutputs = reviews.map(r => r.rawAiOutput); - const inputTokens = reviews.map(r => r.inputTokens); - const outputTokens = reviews.map(r => r.outputTokens); - const durationMs = reviews.map(r => r.durationMs); - const verdicts = reviews.map(r => r.verdict); - const fileSummaries = reviews.map(r => r.fileSummary); - const overallCorrectness = reviews.map(r => r.overallCorrectness ?? null); - const confidenceScores = reviews.map(r => r.confidenceScore ?? null); - const errorMessages = reviews.map(r => r.errorMessage); - const modelProviders = reviews.map(r => r.modelProvider ?? null); - - await queryTransaction(env, async (tx) => { - const insertedRows = await tx.query<{ id: string; file_path: string }>( + return await queryTransaction(env, async (tx) => { + const inserted = await tx.query<{ id: string; file_path: 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 ) - 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[] - ) + SELECT $1::uuid, 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 + FROM file_reviews + WHERE job_id = $2::uuid AND file_status = 'done' AND file_path = ANY($3::text[]) + ON CONFLICT (job_id, file_path) DO NOTHING RETURNING id, file_path `, - [ - jobId, filePaths, fileStatuses, modelsUsed, diffLineCounts, diffInputs, - rawAiOutputs, inputTokens, outputTokens, durationMs, verdicts, - fileSummaries, overallCorrectness, confidenceScores, errorMessages, modelProviders - ] + [input.jobId, input.parentJobId, input.filePaths], ); - 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) { + if (inserted.length > 0) { + // Re-attach each inherited review's comments, mapping the parent's rows to the new ids by path. 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[] - ) + SELECT nw.new_id, rc.path, rc.line, rc.position, rc.severity, rc.category, rc.title, rc.body, rc.code_suggestion + FROM UNNEST($1::uuid[], $2::text[]) AS nw(new_id, file_path) + JOIN file_reviews pf ON pf.job_id = $3::uuid AND pf.file_path = nw.file_path + JOIN review_comments rc ON rc.file_review_id = pf.id `, - [ - 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), - ] + [inserted.map((r) => r.id), inserted.map((r) => r.file_path), input.parentJobId], ); } + + return inserted.map((r) => r.file_path); }); } +/** + * Mark many files 'failed' in a single INSERT. Finalize backfills reviews for files that never got + * one (e.g. files that appeared mid-review, or unrecoverable ones); doing that one-by-one through + * upsertFileReview runs a transaction per file (several Hyperdrive round-trips each), which for a + * large/growing PR can blow the per-invocation subrequest budget right before the review is posted. + * This collapses the whole backfill into one statement (one subrequest). Skips files that already + * have a row (ON CONFLICT DO NOTHING) so it never clobbers a real review. + */ +export async function bulkMarkFilesFailed( + env: Pick, + jobId: string, + files: Array<{ filePath: string; diffLineCount: number }>, + opts: { modelUsed: string; errorMessage: string }, +): Promise { + if (files.length === 0) return; + await queryRows( + env, + ` + INSERT INTO file_reviews (job_id, file_path, file_status, model_used, diff_line_count, diff_input, error_msg, duration_ms) + SELECT $1::uuid, u.file_path, 'failed', $2, u.diff_line_count, '', $3, 0 + FROM UNNEST($4::text[], $5::int[]) AS u(file_path, diff_line_count) + ON CONFLICT (job_id, file_path) DO NOTHING + `, + [jobId, opts.modelUsed, opts.errorMessage, files.map((f) => f.filePath), files.map((f) => f.diffLineCount)], + ); +} +export async function getModelUsageStats(env: Pick) { + return queryRows<{ + model_used: string; + model_provider: string | null; + calls: number; + input_tokens: number | null; + output_tokens: number | null; + }>( + env, + ` + SELECT + model_used, + MIN(model_provider) AS model_provider, + COUNT(*)::int AS calls, + COALESCE(SUM(input_tokens), 0)::int AS input_tokens, + COALESCE(SUM(output_tokens), 0)::int AS output_tokens + FROM file_reviews + GROUP BY model_used + ORDER BY calls DESC, model_used ASC + `, + ); +} export async function getFileReviewsForJobs(env: Pick, jobIds: string[]) { if (jobIds.length === 0) return []; @@ -439,6 +408,8 @@ export async function getFileReviewsForJobs(env: Pick error_msg: string | null; model_provider: string | null; transient_error_count: number; + async_request_id: string | null; + async_model: string | null; }>( env, ` diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index c06b884..8e8c011 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -5,6 +5,7 @@ import { getOrCreateRepository } from './repositories'; export type JobRow = { id: string; + workflow_instance_id: string | null; installation_id: string; owner: string; repo: string; @@ -14,7 +15,7 @@ export type JobRow = { commit_sha: ByteaValue; base_sha: ByteaValue; trigger: 'auto' | 'mention' | 'retry'; - status: 'queued' | 'running' | 'done' | 'failed' | 'superseded'; + status: 'queued' | 'running' | 'done' | 'failed' | 'superseded' | 'cancelled' | 'stopped'; config_snapshot: { review?: RepoConfig['review']; model?: RepoConfig['model'] } | string | null; check_run_id: number | null; check_run_completed_at: string | null; @@ -25,6 +26,7 @@ export type JobRow = { lease_expires_at: string | null; heartbeat_at: string | null; recovery_count: number | null; + continuation_count: number | null; last_queue_message_at: string | null; total_input_tokens: number | null; total_output_tokens: number | null; @@ -137,11 +139,89 @@ export function mapJob(row: JobRow) { checkRunId: row.check_run_id, configSnapshot: row.config_snapshot ? repoConfigSchema.parse(parseJsonColumn(row.config_snapshot, defaultRepoConfig)) : null, retryOfJobId: row.retry_of_job_id, + workflowInstanceId: row.workflow_instance_id, }); } -export async function insertJob( +export async function setJobWorkflowInstance(env: Pick, jobId: string, workflowInstanceId: string) { + await queryRows( + env, + ` + UPDATE jobs + SET workflow_instance_id = $2::uuid + WHERE id = $1 + `, + [jobId, workflowInstanceId], + ); +} + +/** + * Refresh a job's cached PR title/author from the live pull request. The values are snapshotted at + * job-creation time (and copied verbatim onto retries), so a title edited on GitHub afterwards would + * otherwise keep showing stale on the dashboard. Called during prepare once the PR is fetched. + */ +export async function setJobPullRequestMeta( env: Pick, + jobId: string, + meta: { prTitle: string | null; prAuthor: string | null }, +) { + await queryRows( + env, + ` + UPDATE jobs + SET pr_title = $2, + pr_author = COALESCE($3, pr_author) + WHERE id = $1 + `, + [jobId, meta.prTitle, meta.prAuthor], + ); +} + +export async function markSystemActive(env: Pick) { + try { + // claimJobLease() calls this on every review chunk, so a single large PR (dozens of chunks) used + // to issue dozens of identical KV writes -- enough to blow through the Workers-Free daily KV + // write quota. KV reads are far cheaper and have a much higher quota, so read first and only + // write when the flag is actually missing (expired, or the first job after an idle period). The + // 20-minute TTL means an active job refreshes it at most ~once per 20 minutes, not per chunk. + const existing = await env.APP_KV.get('system:active_jobs'); + if (existing) return; + await env.APP_KV.put('system:active_jobs', '1', { expirationTtl: 20 * 60 }); + } catch (error) { + // Ignore KV errors to avoid failing the DB transaction + } +} + +export async function clearSystemActive(env: Pick) { + try { + await env.APP_KV.delete('system:active_jobs'); + } catch (error) { + // Best-effort: the 20-minute TTL on the flag is the backstop if this delete fails. + } +} + +/** + * Whether the scheduled maintenance loop still has anything to do: any job that could still be + * running/recoverable (queued/running) or any terminal job whose GitHub check run hasn't been + * completed yet. When this is false the cron can clear the `system:active_jobs` flag so subsequent + * ticks skip the DB entirely and the serverless Postgres is allowed to suspend. + */ +export async function hasPendingMaintenanceWork(env: Pick): Promise { + const rows = await queryRows<{ has_work: boolean }>( + env, + ` + SELECT EXISTS ( + SELECT 1 FROM jobs + WHERE status IN ('queued', 'running') + OR (status IN ('done', 'failed', 'superseded', 'cancelled') AND check_run_id IS NOT NULL AND check_run_completed_at IS NULL) + ) AS has_work + `, + ); + return rows[0]?.has_work === true; +} + +export async function insertJob( + env: Pick, input: { installationId: string; owner: string; @@ -204,6 +284,7 @@ export async function insertJob( ], ); + await markSystemActive(env); return mapJob(row); } @@ -380,35 +461,8 @@ export async function getJobDetail(env: Pick, jobId: }); } -export async function startJobProcessing(env: Pick, jobId: string, stepName: string) { - const now = new Date().toISOString(); - const rows = await queryRows<{ id: string }>( - env, - ` - UPDATE jobs - SET status = 'running', - started_at = COALESCE(started_at, now()), - steps = COALESCE(steps, '[]'::jsonb) || jsonb_build_array( - jsonb_build_object( - 'name', $2::text, - 'status', 'running', - 'startedAt', $3::text, - 'finishedAt', NULL, - 'error', NULL - ) - ) - WHERE id = $1 - AND status = 'queued' - RETURNING id - `, - [jobId, stepName, now], - ); - - return rows.length > 0; -} - export async function claimJobLease( - env: Pick, + env: Pick, jobId: string, leaseOwner: string, leaseSeconds: number, @@ -447,6 +501,7 @@ export async function claimJobLease( ); if (claimed) { + await markSystemActive(env); return { status: 'claimed', row: claimed }; } @@ -471,7 +526,7 @@ export async function claimJobLease( } export async function heartbeatJobLease( - env: Pick, + env: Pick, jobId: string, leaseOwner: string, leaseSeconds: number, @@ -488,6 +543,7 @@ export async function heartbeatJobLease( `, [jobId, leaseOwner, String(leaseSeconds)], ); + await markSystemActive(env); } export async function releaseJobLease(env: Pick, jobId: string, leaseOwner: string) { @@ -504,21 +560,45 @@ export async function releaseJobLease(env: Pick, jobI ); } +// Records that a job is rescheduling the same phase (a continuation) and returns the resulting +// no-progress continuation count. The counter is bumped here and cleared by +// resetJobContinuationCount() whenever a chunk actually completes a file, so a healthy job that +// keeps making headway stays near zero while a job that can never progress climbs toward the +// MAX_JOB_CONTINUATIONS ceiling and is failed terminally. export async function markJobContinuationQueued(env: Pick, jobId: string, delaySeconds = 0) { - await queryRows( + const rows = await queryRows<{ continuation_count: number }>( env, ` UPDATE jobs SET heartbeat_at = now(), + continuation_count = continuation_count + 1, last_queue_message_at = CASE WHEN $2::int > 0 THEN now() + ($2::text || ' seconds')::interval ELSE now() END WHERE id = $1 AND status = 'running' + RETURNING continuation_count `, [jobId, delaySeconds], ); + return rows[0]?.continuation_count ?? 0; +} + +// Clears the no-progress continuation counter after a chunk completes at least one file review, +// so slow-but-progressing jobs never trip the MAX_JOB_CONTINUATIONS safety net. +export async function resetJobContinuationCount(env: Pick, jobId: string) { + await queryRows( + env, + ` + UPDATE jobs + SET continuation_count = 0 + WHERE id = $1 + AND status = 'running' + AND continuation_count <> 0 + `, + [jobId], + ); } export async function updateJobCheckRun(env: Pick, jobId: string, checkRunId: number) { @@ -556,7 +636,11 @@ export async function completeJob( UPDATE jobs SET status = 'done', finished_at = now(), - check_run_completed_at = now(), + -- NOTE: check_run_completed_at is intentionally NOT set here. It's set only once the + -- GitHub check run has actually been updated (markJobCheckRunCompleted, called after the + -- best-effort updateCheckRun in finalize). That way, if finalize couldn't update the + -- check run (e.g. subrequest budget spent on a huge PR), it stays NULL and the maintenance + -- sweep (completeTerminalCheckRuns) reconciles it -- the check run always ends 'completed'. lease_owner = NULL, lease_expires_at = NULL, verdict = $2, @@ -609,7 +693,7 @@ export async function completeJob( ); } -export async function failJob(env: Pick, jobId: string, errorMessage: string) { +export async function failJob(env: Pick, jobId: string, errorMessage: string) { await queryRows( env, ` @@ -635,6 +719,59 @@ export async function failJob(env: Pick, jobId: strin `, [jobId, errorMessage], ); + await markSystemActive(env); +} + +/** + * Stop an ongoing (queued/running) job at the user's request: mark it terminal as 'cancelled', + * clear the lease (so lease-recovery won't requeue it) and mark any running steps failed. Returns + * true if a job was actually transitioned (false if it was already terminal). The caller is + * responsible for terminating the Cloudflare Workflow instance. + */ +export async function cancelJob(env: Pick, jobId: string): Promise { + const rows = await queryRows<{ id: string }>( + env, + ` + UPDATE jobs + SET status = 'cancelled', + finished_at = now(), + lease_owner = NULL, + lease_expires_at = NULL, + error_msg = COALESCE(error_msg, 'Stopped by user.'), + 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', 'Stopped by user.') + ELSE s + END + ) FROM jsonb_array_elements(steps) s + ) + ELSE steps + END + WHERE id = $1 AND status IN ('queued', 'running') + RETURNING id + `, + [jobId], + ); + // Keep the maintenance flag set so the cron completes the GitHub check run for the cancelled job. + if (rows.length > 0) await markSystemActive(env); + return rows.length > 0; +} + +/** + * Permanently delete a job. file_reviews and review_comments cascade automatically (ON DELETE + * CASCADE); child retry jobs have their retry_of_job_id nulled (ON DELETE SET NULL). Returns true + * if a row was deleted. + */ +export async function deleteJob(env: Pick, jobId: string): Promise { + const rows = await queryRows<{ id: string }>( + env, + `DELETE FROM jobs WHERE id = $1 RETURNING id`, + [jobId], + ); + return rows.length > 0; } export async function markJobCheckRunCompleted(env: Pick, jobId: string) { @@ -737,8 +874,16 @@ export async function updateJobStep( WHEN s->>'name' = $2 THEN s || jsonb_build_object( 'status', $3::text, - 'startedAt', COALESCE($4::text, s->>'startedAt'), - 'finishedAt', COALESCE($5::text, s->>'finishedAt'), + -- Preserve the FIRST start time. A phase (e.g. "Reviewing Files") re-enters + -- 'running' once per hibernated chunk; overwriting startedAt each time would make + -- the displayed duration reflect only the last chunk (~seconds) instead of the full + -- multi-minute wall-clock. Keep the existing start; only seed it when absent. + 'startedAt', COALESCE(s->>'startedAt', $4::text), + -- 'running' clears any stale finish (step is back in progress). Otherwise keep the + -- FIRST finish already recorded for this run -- so re-marking a step 'done' (e.g. + -- finalize defensively re-confirming "Reviewing Files") doesn't push the timestamp + -- later and inflate the displayed duration. A re-run resets it via the 'running' clear. + 'finishedAt', CASE WHEN $3::text = 'running' THEN NULL ELSE COALESCE(s->>'finishedAt', $5::text) END, 'error', COALESCE($6::text, s->>'error') ) ELSE s @@ -761,23 +906,6 @@ export async function updateJobStep( ); } -export async function recoverStaleJobs( - env: Pick, - thresholdMinutes = 20, -): Promise { - const rows = await queryRows<{ id: string }>(env, ` - UPDATE jobs - SET status = 'failed', - finished_at = now(), - error_msg = 'Job timed out: worker crashed or was evicted.' - WHERE status = 'running' - AND started_at < now() - ($1 || ' minutes')::interval - RETURNING id - `, [String(thresholdMinutes)]); - - return rows.length; -} - export async function recoverExpiredJobLeases( env: Pick, maxRecoveryCount = 3, @@ -888,7 +1016,7 @@ export async function getTerminalJobsNeedingCheckRunCompletion( 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') + WHERE j.status IN ('done', 'failed', 'superseded', 'cancelled') 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 @@ -932,3 +1060,12 @@ export async function supersedeOlderJobs( return rows.length; } + +export async function getOtherRunningJobsCount(env: Pick, excludeJobId: string): Promise { + const [result] = await queryRows<{ count: string }>( + env, + `SELECT count(*) as count FROM jobs WHERE status = 'running' AND id != $1`, + [excludeJobId] + ); + return parseInt(result?.count ?? '0', 10); +} diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index 453f51e..51e90b8 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -26,9 +26,6 @@ type ModelConfigRow = { provider_name: string; api_format: LlmApiFormat; model_name: string; - rpm: number | null; - tpm: number | null; - rpd: number | null; updated_at: string; }; @@ -69,9 +66,6 @@ function mapModelConfig(row: ModelConfigRow): ModelConfig { providerName: row.provider_name, apiFormat: row.api_format, modelName: row.model_name, - rpm: row.rpm, - tpm: row.tpm, - rpd: row.rpd, updatedAt: row.updated_at, }); } @@ -83,9 +77,6 @@ const MODEL_SELECT = ` p.name AS provider_name, p.api_format, mc.model_name, - mc.rpm, - mc.tpm, - mc.rpd, mc.updated_at FROM model_configs mc JOIN llm_providers p ON p.id = mc.provider_id @@ -248,9 +239,6 @@ export async function getResolvedModelConfig( p.name AS provider_name, p.api_format, mc.model_name, - mc.rpm, - mc.tpm, - mc.rpd, mc.updated_at, p.enabled AS provider_enabled, p.base_url, @@ -279,20 +267,17 @@ export async function updateModelConfig( env, ` WITH upserted AS ( - INSERT INTO model_configs (model_id, provider_id, model_name, rpm, tpm, rpd, provider, updated_at) - SELECT $1, p.id, $3, $4, $5, $6, p.api_format, now() + INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at) + SELECT $1, p.id, $3, p.api_format, now() FROM llm_providers p WHERE p.id = $2 ON CONFLICT (model_id) DO UPDATE SET provider_id = EXCLUDED.provider_id, model_name = EXCLUDED.model_name, - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, provider = EXCLUDED.provider, updated_at = now() - RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at + RETURNING model_id, provider_id, model_name, updated_at ) SELECT u.model_id, @@ -300,14 +285,11 @@ export async function updateModelConfig( p.name AS provider_name, p.api_format, u.model_name, - u.rpm, - u.tpm, - u.rpd, u.updated_at FROM upserted u JOIN llm_providers p ON p.id = u.provider_id `, - [config.modelId, config.providerId, config.modelName, config.rpm, config.tpm, config.rpd], + [config.modelId, config.providerId, config.modelName], ); return row ? mapModelConfig(row) : null; } @@ -352,9 +334,6 @@ export async function upsertDiscoveredModelConfigs( model_id: string; provider_id: string; model_name: string; - rpm: number | null; - tpm: number | null; - rpd: number | null; provider: LlmApiFormat; }> = []; @@ -374,9 +353,6 @@ export async function upsertDiscoveredModelConfigs( model_id: candidate, provider_id: input.providerId, model_name: modelName, - rpm: null, - tpm: null, - rpd: null, provider: input.apiFormat, }); } @@ -386,9 +362,6 @@ export async function upsertDiscoveredModelConfigs( const modelIds = rowsToInsert.map(row => row.model_id); const providerIds = rowsToInsert.map(row => row.provider_id); const modelNames = rowsToInsert.map(row => row.model_name); - const rpms = rowsToInsert.map(row => row.rpm); - const tpms = rowsToInsert.map(row => row.tpm); - const rpds = rowsToInsert.map(row => row.rpd); const providers = rowsToInsert.map(row => row.provider); const rows = await queryRows( @@ -400,18 +373,15 @@ export async function upsertDiscoveredModelConfigs( $1::text[], $2::uuid[], $3::text[], - $4::integer[], - $5::integer[], - $6::integer[], - $7::text[] - ) AS item(model_id, provider_id, model_name, rpm, tpm, rpd, provider) + $4::text[] + ) AS item(model_id, provider_id, model_name, provider) ), inserted AS ( - INSERT INTO model_configs (model_id, provider_id, model_name, rpm, tpm, rpd, provider, updated_at) - SELECT model_id, provider_id, model_name, rpm, tpm, rpd, provider, now() + INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at) + SELECT model_id, provider_id, model_name, provider, now() FROM incoming ON CONFLICT (model_id) DO NOTHING - RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at + RETURNING model_id, provider_id, model_name, updated_at ) SELECT i.model_id, @@ -419,15 +389,12 @@ export async function upsertDiscoveredModelConfigs( p.name AS provider_name, p.api_format, i.model_name, - i.rpm, - i.tpm, - i.rpd, i.updated_at FROM inserted i JOIN llm_providers p ON p.id = i.provider_id ORDER BY i.model_id ASC `, - [modelIds, providerIds, modelNames, rpms, tpms, rpds, providers], + [modelIds, providerIds, modelNames, providers], ); return rows.map(mapModelConfig); diff --git a/src/server/db/repo-configs.ts b/src/server/db/repo-configs.ts index b3d1c71..933dec8 100644 --- a/src/server/db/repo-configs.ts +++ b/src/server/db/repo-configs.ts @@ -105,6 +105,39 @@ export async function syncRepoConfig( ); } +export async function deleteStaleRepoConfigs( + env: Pick, + installationId: string, + activeRepoFullNames: string[] +) { + if (activeRepoFullNames.length === 0) { + await queryRows( + env, + ` + DELETE FROM repo_configs + WHERE repository_id IN ( + SELECT id FROM repositories WHERE installation_id = $1 + ) + `, + [installationId] + ); + return; + } + + await queryRows( + env, + ` + DELETE FROM repo_configs + WHERE repository_id IN ( + SELECT id FROM repositories + WHERE installation_id = $1 + AND owner || '/' || repo != ALL($2::text[]) + ) + `, + [installationId, activeRepoFullNames] + ); +} + export async function updateRepoConfigEnabled( env: Pick, input: { diff --git a/src/server/db/stats.ts b/src/server/db/stats.ts index 42d1175..457aa65 100644 --- a/src/server/db/stats.ts +++ b/src/server/db/stats.ts @@ -1,13 +1,18 @@ import type { AppBindings } from '@server/env'; import { queryRows } from './client'; -import { statsSchema } from '@shared/schema'; +import { statsSchema, jobStatuses, reviewTriggers, reviewSeverities, reviewCategories } from '@shared/schema'; import { getModelUsageStats } from './file-reviews'; +const jobStatusSet = new Set(jobStatuses); +const reviewTriggerSet = new Set(reviewTriggers); +const reviewSeveritySet = new Set(reviewSeverities); +const reviewCategorySet = new Set(reviewCategories); + export async function getStats(env: Pick, days = 30) { const parsedDays = Number(days); const safeDays = Number.isFinite(parsedDays) ? Math.trunc(parsedDays) : 30; const clampedDays = Math.min(Math.max(safeDays, 1), 365); - const [[totals], dailyRows, verdictRows, topRepos, modelRows] = await Promise.all([ + const [[totals], dailyRows, verdictRows, topRepos, modelRows, statusRows, triggerRows, severityRows, categoryRows, [performanceRow]] = await Promise.all([ queryRows<{ jobs: number; input_tokens: number; @@ -61,6 +66,64 @@ export async function getStats(env: Pick, days = 30) `, ), getModelUsageStats(env), + queryRows<{ status: string; count: number }>( + env, + ` + SELECT status, COUNT(*)::int AS count + FROM jobs + WHERE created_at >= now() - ($1::int * interval '1 day') + GROUP BY status + ORDER BY count DESC + `, + [clampedDays], + ), + queryRows<{ trigger: string; count: number }>( + env, + ` + SELECT trigger, COUNT(*)::int AS count + FROM jobs + WHERE created_at >= now() - ($1::int * interval '1 day') + GROUP BY trigger + ORDER BY count DESC + `, + [clampedDays], + ), + queryRows<{ severity: string; count: number }>( + env, + ` + SELECT rc.severity, COUNT(*)::int AS count + FROM review_comments rc + JOIN file_reviews fr ON fr.id = rc.file_review_id + WHERE fr.created_at >= now() - ($1::int * interval '1 day') + GROUP BY rc.severity + ORDER BY count DESC + `, + [clampedDays], + ), + queryRows<{ category: string; count: number }>( + env, + ` + SELECT rc.category, COUNT(*)::int AS count + FROM review_comments rc + JOIN file_reviews fr ON fr.id = rc.file_review_id + WHERE fr.created_at >= now() - ($1::int * interval '1 day') + GROUP BY rc.category + ORDER BY count DESC + `, + [clampedDays], + ), + queryRows<{ avg_duration_ms: number | null; p95_duration_ms: number | null; avg_confidence: number | null }>( + env, + ` + SELECT + AVG(EXTRACT(EPOCH FROM (finished_at - started_at)) * 1000) AS avg_duration_ms, + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (finished_at - started_at)) * 1000) AS p95_duration_ms, + AVG(overall_confidence_score) AS avg_confidence + FROM jobs + WHERE finished_at IS NOT NULL AND started_at IS NOT NULL AND created_at >= now() - ($1::int * interval '1 day') + `, + [clampedDays], + ), ]); return statsSchema.parse({ @@ -86,5 +149,17 @@ export async function getStats(env: Pick, days = 30) outputTokens: row.output_tokens ?? 0, })), topRepos: topRepos.map((row) => ({ owner: row.owner, repo: row.repo, jobs: row.jobs })), + // Drop any rows whose enum-typed column holds an unexpected value (e.g. legacy + // rows back-migrated from JSON, where severity/category have no DB CHECK + // constraint). Keeping them would fail statsSchema.parse and 500 the endpoint. + statuses: statusRows.filter((row) => jobStatusSet.has(row.status)).map((row) => ({ status: row.status as (typeof jobStatuses)[number], count: row.count })), + triggers: triggerRows.filter((row) => reviewTriggerSet.has(row.trigger)).map((row) => ({ trigger: row.trigger as (typeof reviewTriggers)[number], count: row.count })), + severities: severityRows.filter((row) => reviewSeveritySet.has(row.severity)).map((row) => ({ severity: row.severity as (typeof reviewSeverities)[number], count: row.count })), + categories: categoryRows.filter((row) => reviewCategorySet.has(row.category)).map((row) => ({ category: row.category as (typeof reviewCategories)[number], count: row.count })), + performance: { + avgDurationMs: performanceRow?.avg_duration_ms != null ? Math.round(performanceRow.avg_duration_ms) : null, + p95DurationMs: performanceRow?.p95_duration_ms != null ? Math.round(performanceRow.p95_duration_ms) : null, + avgConfidence: performanceRow?.avg_confidence ?? null, + }, }); } diff --git a/src/server/env.ts b/src/server/env.ts index 34dddf1..e15e2a3 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -1,7 +1,7 @@ import type { ReviewJobMessage } from '@shared/schema'; export interface WorkersAiBinding { - run(model: string, input: Record): Promise; + run(model: string, input: Record, options?: { signal?: AbortSignal }): Promise; } export interface QueueProducer { @@ -29,6 +29,7 @@ export interface AppBindings { AI: WorkersAiBinding; APP_KV: KVNamespace; REVIEW_QUEUE: QueueProducer; + REVIEW_WORKFLOW: Workflow; ASSETS: AssetsBinding; HYPERDRIVE: HyperdriveBinding; APP_PRIVATE_KEY: string; @@ -45,7 +46,6 @@ export interface AppBindings { ENVIRONMENT: string; CF_API_TOKEN: string; CF_ACCOUNT_ID: string; - CF_DLQ_ID: string; } export interface AppVariables { diff --git a/src/server/index.ts b/src/server/index.ts index 6b52af1..3356b86 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,18 +1,51 @@ import { createApp } from './app'; -import { runReviewJob } from './core/review'; +import { ReviewWorkflow } from './workflows/review'; import type { AppBindings } from './env'; import { reviewJobMessageSchema } from '@shared/schema'; import { logger } from '@server/core/logger'; import { runWithDb } from '@server/db/client'; +import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@server/db/jobs'; import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; const app = createApp(); +export { ReviewWorkflow }; + export default { fetch(request: Request, env: AppBindings, ctx: ExecutionContext) { return runWithDb(env, () => app.fetch(request, env, ctx)); }, + async scheduled(_controller: ScheduledController, env: AppBindings, _ctx: ExecutionContext) { + // The cron fires every 2 minutes, but its only job is maintenance (recovering stuck jobs and + // finishing check runs). Touching Postgres on every tick would keep the serverless DB awake + // 24/7. So gate the DB work on a KV flag that is set whenever a job is created/claimed and + // cleared below once there is genuinely nothing left to maintain. When the flag is absent we + // return without ever opening a DB connection, letting Postgres suspend. + try { + const active = await env.APP_KV.get('system:active_jobs'); + if (!active) { + return; + } + } catch (error) { + logger.warn('Failed to read active jobs flag from KV, proceeding with maintenance', error instanceof Error ? error : new Error(String(error))); + } + + return runWithDb(env, async () => { + await runBestEffortJobMaintenance(env); + // As soon as no jobs are running/recoverable and no check runs are outstanding, drop the + // flag so the next tick skips Postgres entirely (instead of waiting out the 20-minute TTL). + // A new job re-sets the flag on insert/claim, so this only trims the idle tail. + try { + if (!(await hasPendingMaintenanceWork(env))) { + await clearSystemActive(env); + } + } catch (error) { + logger.warn('Failed to evaluate pending maintenance work; leaving active-jobs flag to expire via TTL', error instanceof Error ? error : new Error(String(error))); + } + }); + }, + async queue(batch: MessageBatch, env: AppBindings, _ctx: ExecutionContext) { return runWithDb(env, async () => { try { @@ -25,24 +58,67 @@ export default { const parseResult = reviewJobMessageSchema.safeParse(message.body); if (!parseResult.success) { - logger.error('Invalid queue message schema; retrying so it can reach the DLQ', { + logger.error('Invalid queue message schema; dropping message', { body: message.body, error: parseResult.error.flatten(), }); - message.retry(); + // A malformed message can't be processed and retrying won't help, so ack it -- but if it + // still carries a recognizable jobId, fail that job so it doesn't sit 'queued' forever + // (lease recovery only revives 'running' rows). + const strandedId = (message.body as { jobId?: unknown })?.jobId; + if (typeof strandedId === 'string' && /^[0-9a-f-]{36}$/i.test(strandedId)) { + try { + await failJob(env, strandedId, 'Review dropped: the queue message failed schema validation.'); + } catch (failError) { + logger.error('Failed to fail job stranded by an invalid queue message', failError instanceof Error ? failError : new Error(String(failError))); + } + } + message.ack(); continue; } try { - const result = await runReviewJob(env, parseResult.data); - if (result.action === 'retry') { - message.retry({ delaySeconds: result.delaySeconds }); - } else { + // Recovery re-enqueues a stuck job under its original jobId; keying the instance on jobId + // would collide with the dead instance (instance.already_exists) and get dropped as a + // duplicate, so recovery sets forceFreshInstance to key the new instance on the (fresh) + // deliveryId. deliveryId is a UUID, matching the workflow_instance_id column type. + const id = parseResult.data.forceFreshInstance + ? parseResult.data.deliveryId + : (parseResult.data.jobId ?? parseResult.data.deliveryId); + if (!id) { + logger.error('Message missing identifiers; dropping', { body: message.body }); message.ack(); + continue; } + await env.REVIEW_WORKFLOW.create({ + id, + params: parseResult.data, + }); + message.ack(); } catch (error) { - logger.error('Queue message processing failed; retrying', error instanceof Error ? error : new Error(String(error))); - message.retry(); + if (error instanceof Error && error.message.includes('instance.already_exists')) { + logger.info('Workflow instance already exists; dropping duplicate queue message.', { + jobId: parseResult.data.jobId, + deliveryId: parseResult.data.deliveryId, + }); + message.ack(); + continue; + } + + logger.error('Failed to create workflow', error instanceof Error ? error : new Error(String(error))); + if (message.attempts >= 3) { + const id = parseResult.data.jobId ?? parseResult.data.deliveryId; + if (id) { + try { + await failJob(env, id, 'Failed to start Cloudflare Workflow after multiple attempts. The Cloudflare infrastructure might be experiencing an outage.'); + } catch (failError) { + logger.error('Critical: Failed to mark job as failed in DB', failError instanceof Error ? failError : new Error(String(failError))); + } + } + message.ack(); + } else { + message.retry(); + } } } diff --git a/src/server/models/anthropic.ts b/src/server/models/anthropic.ts index dab3da4..423bf3d 100644 --- a/src/server/models/anthropic.ts +++ b/src/server/models/anthropic.ts @@ -2,7 +2,7 @@ import { logger } from '@server/core/logger'; import { withTimeout } from '@server/core/timeout'; import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; -const ANTHROPIC_TIMEOUT_MS = 180_000; +const ANTHROPIC_TIMEOUT_MS = 80_000; const ANTHROPIC_MAX_OUTPUT_TOKENS = 4096; const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; @@ -15,16 +15,17 @@ export interface AnthropicResponse { } export async function reviewWithAnthropic( - config: { apiKey: string; baseUrl?: string | null; providerName: string }, + config: { apiKey: string; baseUrl?: string | null; providerName: string; timeoutMs?: number }, model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, ): Promise { logger.info(`Calling Anthropic model: ${model}`); const baseUrl = (config.baseUrl || DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, ''); + const timeoutMs = config.timeoutMs ?? ANTHROPIC_TIMEOUT_MS; if (tracker) tracker.incrementSubrequests(1); - const response = await withTimeout('Anthropic API', ANTHROPIC_TIMEOUT_MS, (signal) => + const response = await withTimeout('Anthropic API', timeoutMs, (signal) => fetch(`${baseUrl}/messages`, { method: 'POST', signal, diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 8436c75..9b0c50f 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -1,10 +1,17 @@ import { logger } from '@server/core/logger'; import type { AppBindings } from '@server/env'; import { TimeoutError } from '@server/core/timeout'; -import { ProviderRequestError, type ModelResponse } from './types'; +import { ProviderRequestError, UnparseableModelResponseError, type ModelResponse } from './types'; -/** Max wall-clock time allowed for a single Workers-AI call. */ -const CLOUDFLARE_TIMEOUT_MS = 180_000; +/** + * Default max wall-clock time allowed for a single Workers-AI call when the caller doesn't + * supply a diff-size-aware budget. Kept well under the review workflow's 15-minute step + * timeout: a model that hasn't answered a code-review prompt in this long (reasoning models + * under strict-JSON decoding are the usual offenders -- they burn the whole token budget + * "thinking" and never emit the JSON) is not going to, so we fail fast and let the file defer + * to a fresh invocation instead of stalling the whole review. + */ +const CLOUDFLARE_TIMEOUT_MS = 45_000; const CLOUDFLARE_MAX_RETRIES = 0; const CLOUDFLARE_MAX_OUTPUT_TOKENS = 8192; const REVIEW_RESPONSE_SCHEMA = { @@ -71,12 +78,6 @@ function getRecord(value: unknown, key: string): UnknownRecord | null { 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]; @@ -89,16 +90,9 @@ function isLocalWorkersAiBindingError(error: unknown) { 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 failUnparseable(model: string, reason: string): never { + logger.warn(`Cloudflare model ${model} returned no parseable review content; failing the file review`, { reason }); + throw new UnparseableModelResponseError(model, reason); } function extractMessageContent(content: unknown): string | null { @@ -119,13 +113,32 @@ function extractMessageContent(content: unknown): string | null { return null; } +// A model's completion arrives in `response`, but the type varies by model: most return a +// plain string, while models honoring `response_format`/structured output (e.g. +// @cf/qwen/qwen2.5-coder-32b-instruct) return an already-parsed JSON object or array. Accept +// both -- stringify structured values so the downstream repair/parse pipeline can consume them +// instead of the extractor discarding a perfectly good review as an "empty response". +function extractResponseField(container: unknown): string | null { + if (!isRecord(container)) return null; + const value = container.response; + if (isText(value)) return value.trim(); + if (value && typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return null; + } + } + return null; +} + function extractCloudflareText(result: unknown, model: string): string { if (isText(result)) return result.trim(); - const response = getText(result, 'response'); + const response = extractResponseField(result); if (response) return response; const nestedResult = getRecord(result, 'result'); - const nestedResponse = getText(nestedResult, 'response'); + const nestedResponse = extractResponseField(nestedResult); if (nestedResponse) return nestedResponse; const choices = isRecord(result) && Array.isArray(result.choices) ? result.choices : null; @@ -137,14 +150,14 @@ function extractCloudflareText(result: unknown, model: string): string { const finishReason = isRecord(choice) ? choice.finish_reason ?? choice.stop_reason : null; const reasoning = isText(message?.reasoning) ? message.reasoning : isText(message?.reasoning_content) ? message.reasoning_content : null; if (reasoning) { - return synthesizeInconclusiveReview(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); + return failUnparseable(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); } if (finishReason) { - return synthesizeInconclusiveReview(model, `finish_reason=${String(finishReason)}`); + return failUnparseable(model, `finish_reason=${String(finishReason)}`); } - return synthesizeInconclusiveReview(model, 'empty response'); + return failUnparseable(model, 'empty response'); } function extractCloudflareUsage(result: unknown) { @@ -155,21 +168,155 @@ function extractCloudflareUsage(result: unknown) { }; } +/** + * The single-request inference payload sent to Workers AI. Shared by the synchronous path and + * the asynchronous batch path so both send an identical prompt/schema/decoding configuration. + */ +function buildCloudflareInferenceRequest(input: { systemPrompt: string; userPrompt: string }) { + return { + messages: [ + { + role: 'system', + content: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`, + }, + { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` }, + ], + max_completion_tokens: CLOUDFLARE_MAX_OUTPUT_TOKENS, + response_format: { + type: 'json_schema', + json_schema: { + name: 'codra_file_review', + strict: true, + schema: REVIEW_RESPONSE_SCHEMA, + }, + }, + temperature: 0, + top_p: 0.1, + } as const; +} + +/** + * Result of polling an async batch request. `pending` means the batch is still queued/running + * on Workers AI (poll again later); `done` carries the extracted review. + */ +export type CloudflareBatchPollResult = + | { status: 'pending' } + | { status: 'done'; response: ModelResponse }; + +function extractBatchStatus(result: unknown): string | null { + if (!isRecord(result)) return null; + const status = result.status ?? getRecord(result, 'result')?.status; + return typeof status === 'string' ? status.toLowerCase() : null; +} + +/** + * Finds the single inference result inside a completed batch poll response. Workers AI has + * returned a few shapes for this over time (a top-level `responses` array, a nested + * `result.responses`, or a bare single result), so probe defensively and fall back to treating + * the whole payload as one result. + */ +function extractBatchInnerResult(result: unknown): unknown { + const containers = [result, isRecord(result) ? result.result : undefined]; + for (const container of containers) { + if (!isRecord(container)) continue; + const responses = container.responses ?? container.results; + if (Array.isArray(responses) && responses.length > 0) { + const first = responses[0]; + // Each entry may wrap the model output under `result`/`response`, or be it directly. + if (isRecord(first)) return first.result ?? first; + return first; + } + } + return result; +} + +/** + * Submit a single review as an asynchronous batch request. Returns the queue `request_id` to + * poll later. Throws if the model/account does not support async queueing (the caller is + * expected to fall back to the synchronous path on any failure). + */ +export async function submitCloudflareBatch( + env: Pick, + model: string, + input: { systemPrompt: string; userPrompt: string }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + if (tracker) tracker.incrementSubrequests(1); + logger.info(`Submitting async batch request to Cloudflare model: ${model}`); + const result = await env.AI.run( + model as any, + { requests: [buildCloudflareInferenceRequest(input)] } as any, + { queueRequest: true } as any, + ); + + const requestId = isRecord(result) + ? (result.request_id ?? getRecord(result, 'result')?.request_id) + : undefined; + if (typeof requestId !== 'string' || !requestId) { + throw new Error(`Cloudflare model ${model} did not return an async batch request_id (async queueing unsupported).`); + } + return requestId; +} + +/** + * Poll a previously submitted async batch request by its `request_id`. Returns `pending` while + * the batch is still queued/running, or `done` with the extracted review once complete. + */ +export async function pollCloudflareBatch( + env: Pick, + model: string, + requestId: string, + tracker?: { incrementSubrequests(count?: number): void }, + providerName = 'Cloudflare', +): Promise { + if (tracker) tracker.incrementSubrequests(1); + const result = await env.AI.run(model as any, { request_id: requestId } as any); + + const status = extractBatchStatus(result); + if (status === 'queued' || status === 'running') { + return { status: 'pending' }; + } + + const inner = extractBatchInnerResult(result); + const rawText = extractCloudflareText(inner, model); + const usage = extractCloudflareUsage(inner); + return { + status: 'done', + response: { + rawText, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + modelUsed: model, + provider: providerName, + }, + }; +} + export async function reviewWithCloudflare( env: Pick, model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, providerName = 'Cloudflare', + options?: { timeoutMs?: number }, ): Promise { const maxRetries = CLOUDFLARE_MAX_RETRIES; + const timeoutMs = options?.timeoutMs ?? CLOUDFLARE_TIMEOUT_MS; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { let timer: ReturnType | undefined; + // Abort the underlying Workers-AI request when the timeout fires. Promise.race on its own + // only stops *us* awaiting -- the subrequest would keep running in the background, holding + // this invocation's wall-clock and pushing the workflow toward its 15-minute step cap long + // after we've given up. Aborting via the AI binding's signal actually cancels it. + const controller = new AbortController(); const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout(() => reject(new TimeoutError(`Cloudflare (${model})`, CLOUDFLARE_TIMEOUT_MS)), CLOUDFLARE_TIMEOUT_MS); + timer = setTimeout(() => { + controller.abort(); + reject(new TimeoutError(`Cloudflare (${model})`, timeoutMs)); + }, timeoutMs); }); try { @@ -182,29 +329,11 @@ export async function reviewWithCloudflare( logger.info(`Calling Cloudflare model: ${model}`); const startTime = Date.now(); - const result = await Promise.race([ - env.AI.run(model as any, { - messages: [ - { - role: 'system', - content: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`, - }, - { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` }, - ], - max_completion_tokens: CLOUDFLARE_MAX_OUTPUT_TOKENS, - response_format: { - type: 'json_schema', - json_schema: { - name: 'codra_file_review', - strict: true, - schema: REVIEW_RESPONSE_SCHEMA, - }, - }, - temperature: 0, - top_p: 0.1, - }), - timeoutPromise, - ]); + const runPromise = env.AI.run(model as any, buildCloudflareInferenceRequest(input), { signal: controller.signal }); + // Once the timeout wins the race the aborted run still settles (as a rejection); attach a + // no-op handler so that late rejection can't surface as an unhandled promise rejection. + runPromise.catch(() => {}); + const result = await Promise.race([runPromise, timeoutPromise]); const durationMs = Date.now() - startTime; logger.info(`AI model ${model} responded in ${durationMs}ms`); diff --git a/src/server/models/google.ts b/src/server/models/google.ts index 974c7ba..f7d8932 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -1,15 +1,58 @@ import { logger } from '@server/core/logger'; import { withTimeout } from '@server/core/timeout'; -import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; - -/** Max wall-clock time allowed for a single Google AI Studio call. */ -const GEMINI_TIMEOUT_MS = 180_000; -const GEMINI_MAX_RETRIES = 1; -const GEMINI_MAX_OUTPUT_TOKENS = 4096; +import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, type ModelResponse } from './types'; + +/** Default max wall-clock time for a single Google AI Studio call when the caller doesn't + * supply a diff-size-aware budget. */ +const GEMINI_TIMEOUT_MS = 45_000; +// Retry transient upstream failures (Gemini's frequent 5xx "Internal error encountered.") a +// couple of times with backoff so a momentary blip doesn't fail an otherwise-fine gemma review. +const GEMINI_MAX_RETRIES = 2; +// Headroom so reasoning/"thinking" models (the gemma-4 family) can spend tokens thinking and +// still emit the JSON answer instead of getting truncated at the limit with an empty body. +const GEMINI_MAX_OUTPUT_TOKENS = 8192; +// Hard cap on any single in-call retry sleep. A 429 can carry a `retry-after` of 30-60s on the +// Free tier; sleeping that long in-call would pin a model-call-gate slot and burn the chunk's +// wall-clock budget for a retry that will just 429 again. Cap it low -- if the provider needs a +// longer cool-off, the file is deferred and resumes in a fresh invocation (which is the real, +// budget-resetting backoff), so a long in-call sleep buys nothing. +const GEMINI_MAX_RETRY_DELAY_MS = 5_000; const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; function isRetryableGeminiStatus(status: number) { - return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; + return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; +} + +function defaultRetryDelayMs(attempt: number) { + // Snappy backoff: a transient Gemini 5xx usually clears within a second or two, and long sleeps + // just eat into the caller's wall-clock and subrequest budget. ~0.8s then ~1.6s for the retries. + return Math.pow(2, attempt) * 800 + Math.random() * 400; +} + +function retryAfterDelayMs(value: string | null) { + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const dateMs = Date.parse(value); + if (Number.isFinite(dateMs)) { + return Math.max(0, dateMs - Date.now()); + } + + return null; +} + +function isRetryableTransportError(error: unknown) { + if (!(error instanceof Error)) return false; + // Deliberately do NOT retry timeouts: the caller already grants a diff-size-aware budget (up to + // 2 minutes), so a call that blows it is a genuinely slow/stuck generation -- retrying just + // burns more wall-clock and subrequests into the same wall. Fail fast and let the fallback chain + // (or a fresh-budget continuation) take over. Only genuine transport blips are worth a retry. + if (error.name === 'TimeoutError' || error.message.toLowerCase().includes('timed out')) return false; + if (error.message.includes('fetch failed')) return true; + return error instanceof TypeError; } function isPrivateIP(hostname: string) { @@ -39,11 +82,12 @@ function isValidPublicUrl(urlString: string) { } export async function reviewWithGoogle( - config: { apiKey: string; baseUrl?: string | null; providerName?: string }, + config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number }, model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, ): Promise { + const timeoutMs = config.timeoutMs ?? GEMINI_TIMEOUT_MS; logger.info(`Calling Google model: ${model}`); if (config.baseUrl && !isValidPublicUrl(config.baseUrl)) { @@ -55,17 +99,19 @@ export async function reviewWithGoogle( const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`; const maxRetries = GEMINI_MAX_RETRIES; let lastError: unknown; + let delayBeforeAttemptMs = 0; for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (delayBeforeAttemptMs > 0) { + logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delayBeforeAttemptMs)}ms`); + await new Promise(resolve => setTimeout(resolve, delayBeforeAttemptMs)); + delayBeforeAttemptMs = 0; + } + + let response: Response; try { if (tracker) tracker.incrementSubrequests(1); - if (attempt > 0) { - const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000; - logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delay)}ms`); - await new Promise(resolve => setTimeout(resolve, delay)); - } - - const response = await withTimeout('Gemini API', GEMINI_TIMEOUT_MS, (signal) => + response = await withTimeout('Gemini API', timeoutMs, (signal) => fetch(url, { method: 'POST', signal, @@ -74,6 +120,7 @@ export async function reviewWithGoogle( }, body: JSON.stringify({ systemInstruction: { + role: 'system', parts: [{ text: input.systemPrompt }], }, contents: [ @@ -83,64 +130,81 @@ export async function reviewWithGoogle( }, ], generationConfig: { - responseMimeType: 'application/json', + ...(model.toLowerCase().includes('gemma') ? {} : { responseMimeType: 'application/json' }), maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, }, }), }), ); - - if (!response.ok) { - const errorText = await response.text(); - const message = providerErrorMessage(errorText); - const isRetryable = isRetryableGeminiStatus(response.status); - - const logData = { - error: message, - attempt, - willRetry: isRetryable && attempt < maxRetries, - }; - if (isRetryable && attempt < maxRetries) { - logger.warn(`Gemini request failed with ${response.status}; retrying`, logData); - lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); - continue; - } - - logger.error(`Gemini request failed with ${response.status}`, logData); - throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + } catch (error) { + lastError = error; + if (isRetryableTransportError(error) && attempt < maxRetries) { + delayBeforeAttemptMs = defaultRetryDelayMs(attempt); + continue; } + throw error; + } - const durationMs = Date.now() - startTime; - logger.info(`AI model ${model} responded in ${durationMs}ms`); + if (!response.ok) { + const errorText = await response.text(); + const message = providerErrorMessage(errorText); + const isRetryable = isRetryableGeminiStatus(response.status); + const retryDelayMs = Math.min( + GEMINI_MAX_RETRY_DELAY_MS, + response.status === 429 + ? retryAfterDelayMs(response.headers.get('retry-after')) ?? defaultRetryDelayMs(attempt) + : defaultRetryDelayMs(attempt), + ); - const data = (await response.json()) as { - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; - usageMetadata?: { - promptTokenCount?: number; - candidatesTokenCount?: number; - }; + const logData = { + error: message, + attempt, + willRetry: isRetryable && attempt < maxRetries, + retryDelayMs: isRetryable && attempt < maxRetries ? retryDelayMs : undefined, }; - - const rawText = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); - if (!rawText) { - throw new Error('Gemini returned an empty response.'); + if (isRetryable && attempt < maxRetries) { + logger.warn(`Gemini request failed with ${response.status}; retrying`, logData); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + delayBeforeAttemptMs = retryDelayMs; + continue; } - return { - rawText, - inputTokens: data.usageMetadata?.promptTokenCount ?? 0, - outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - modelUsed: model, - provider: config.providerName ?? 'Google', + logger.error(`Gemini request failed with ${response.status}`, logData); + throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + } + + const durationMs = Date.now() - startTime; + logger.info(`AI model ${model} responded in ${durationMs}ms`); + + const data = (await response.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; finishReason?: string }>; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; }; - } catch (error) { - lastError = error; - const isTimeout = error instanceof Error && (error.name === 'TimeoutError' || error.message.includes('timeout')); - if (isTimeout && attempt < maxRetries) { - continue; + }; + + const candidate = data.candidates?.[0]; + const rawText = candidate?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); + if (!rawText) { + const finishReason = candidate?.finishReason; + // A reasoning/"thinking" model can consume its whole output budget before emitting any + // answer text, returning empty parts with finishReason MAX_TOKENS (or a safety/RECITATION + // block). That's deterministic, so fail the file permanently rather than deferring it for a + // retry that would hit the same wall. A truly empty STOP response is treated as transient. + if (finishReason && finishReason !== 'STOP') { + throw new UnparseableModelResponseError(model, `finishReason=${finishReason}`); } - throw error; + throw new Error('Gemini returned an empty response.'); } + + return { + rawText, + inputTokens: data.usageMetadata?.promptTokenCount ?? 0, + outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, + modelUsed: model, + provider: config.providerName ?? 'Google', + }; } throw lastError; diff --git a/src/server/models/limits.ts b/src/server/models/limits.ts new file mode 100644 index 0000000..07bb711 --- /dev/null +++ b/src/server/models/limits.ts @@ -0,0 +1,99 @@ +/** + * Shared throttling/timeout policy for outbound model calls. + * + * Two hard Cloudflare Workers Free-plan constraints shape everything here: + * + * 1. Each Worker invocation may have at most SIX connections simultaneously waiting for + * response headers. Anything beyond that (fetch, AI binding, KV, Hyperdrive) is silently + * QUEUED by the runtime -- it does not error, it just doesn't start. A model call that sits + * queued behind other long-running model calls will burn its own client-side timeout without + * the request ever being dispatched, which shows up in logs as a provider "timing out" at + * exactly the configured timeout on every attempt. + * 2. 50 subrequests per invocation, so retries are expensive and every queued-then-timed-out + * call is a wasted subrequest. + */ + +/** Base wall-clock budget for a model call reviewing a small diff. A fast, suitable model + * (e.g. a Gemini flash model) answers in ~1-5s, so 20s is generous headroom; anything slower is a + * stuck/overloaded/queued model and we fail over quickly. */ +export const MODEL_TIMEOUT_BASE_MS = 20_000; +/** Extra time granted per diff line beyond MODEL_TIMEOUT_FREE_LINES -- large diffs legitimately + * need longer generations. */ +export const MODEL_TIMEOUT_PER_LINE_MS = 100; +/** Diff lines included in the base budget before per-line scaling kicks in. */ +export const MODEL_TIMEOUT_FREE_LINES = 100; +/** + * Hard ceiling for a single model call. CRITICAL: this must stay well under the Cloudflare Worker + * *invocation* wall-clock limit (~120s). If one call is allowed to run near 120s (as the old + * 120_000 ceiling permitted for large diffs), a hung/queued call makes the whole workflow + * invocation get killed as `exceededCpu` -- losing all progress and looping -- instead of the call + * timing out gracefully and failing over. 40s leaves room for a couple of sequential fallback + * attempts within one invocation while never approaching the platform limit. + */ +export const MODEL_TIMEOUT_MAX_MS = 40_000; + +/** + * Wall-clock budget for a single file's entire fallback chain within one invocation. Even with a + * short per-call ceiling, a file that fails over through many configured models could otherwise + * run several calls back-to-back and push the invocation past the ~120s platform limit. Once a + * file's chain has spent this long, stop trying more models and defer it -- it resumes from the + * (fast) primary model in a fresh invocation. Keeps one file's chain safely under the invocation cap. + */ +export const MODEL_FALLBACK_CHAIN_BUDGET_MS = 55_000; + +/** + * Wall-clock timeout for one model call, scaled by the size of the (already truncated) diff + * the model has to review. Small diffs fail over fast; large diffs get up to MODEL_TIMEOUT_MAX_MS. + */ +export function adaptiveModelTimeoutMs(diffLineCount: number | null | undefined): number { + const lines = typeof diffLineCount === 'number' && Number.isFinite(diffLineCount) ? Math.max(0, diffLineCount) : 0; + const scaled = MODEL_TIMEOUT_BASE_MS + Math.max(0, lines - MODEL_TIMEOUT_FREE_LINES) * MODEL_TIMEOUT_PER_LINE_MS; + return Math.min(MODEL_TIMEOUT_MAX_MS, scaled); +} + +/** + * Max model calls in flight at once for a single invocation. Kept below the runtime's + * 6-connection cap so short-lived KV/Hyperdrive/GitHub requests issued by concurrent file + * reviews still have free connection slots and model calls are never queued behind each other + * by the runtime (queued calls burn their timeout without ever being dispatched). + */ +export const MAX_CONCURRENT_MODEL_CALLS = 3; + +/** + * Tiny FIFO semaphore. Callers wait *before* their provider timeout starts, so waiting for a + * slot never eats into a model call's own time budget. + */ +export class ModelCallGate { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly limit = MAX_CONCURRENT_MODEL_CALLS) {} + + async run(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.active < this.limit) { + this.active++; + return Promise.resolve(); + } + return new Promise((resolve) => this.waiters.push(resolve)); + } + + private release() { + // Hand the slot directly to the next waiter (active count unchanged) so a newly arriving + // caller can't sneak in between the release and the waiter resuming. + const next = this.waiters.shift(); + if (next) { + next(); + } else { + this.active--; + } + } +} diff --git a/src/server/models/openai.ts b/src/server/models/openai.ts index 11f4540..aa13ab6 100644 --- a/src/server/models/openai.ts +++ b/src/server/models/openai.ts @@ -2,7 +2,7 @@ import { logger } from '@server/core/logger'; import { withTimeout } from '@server/core/timeout'; import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; -const OPENAI_TIMEOUT_MS = 180_000; +const OPENAI_TIMEOUT_MS = 80_000; const OPENAI_MAX_OUTPUT_TOKENS = 4096; export interface OpenAIResponse { @@ -63,12 +63,13 @@ function isValidPublicUrl(urlString: string) { } export async function reviewWithOpenAI( - config: { apiKey: string | null; baseUrl: string; providerName: string }, + config: { apiKey: string | null; baseUrl: string; providerName: string; timeoutMs?: number }, model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, ): Promise { logger.info(`Calling OpenAI-format model: ${model}`); + const timeoutMs = config.timeoutMs ?? OPENAI_TIMEOUT_MS; if (!isValidPublicUrl(config.baseUrl)) { throw new ProviderRequestError(config.providerName, 400, 'Invalid provider base URL.'); @@ -77,7 +78,7 @@ export async function reviewWithOpenAI( const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`; if (tracker) tracker.incrementSubrequests(1); - const response = await withTimeout('OpenAI API', OPENAI_TIMEOUT_MS, (signal) => + const response = await withTimeout('OpenAI API', timeoutMs, (signal) => fetch(url, { method: 'POST', signal, diff --git a/src/server/models/types.ts b/src/server/models/types.ts index ece7258..189a24e 100644 --- a/src/server/models/types.ts +++ b/src/server/models/types.ts @@ -17,6 +17,20 @@ export class ProviderRequestError extends Error { } } +/** + * Thrown when a model responds but produces no reviewable output -- reasoning/thinking only, a + * response truncated at the token limit, or an empty body. The file was NOT actually reviewed, so + * rather than synthesizing a fake "inconclusive" pass we throw: the fallback chain tries the next + * model, and if none succeed the file is honestly marked `failed`. Treated as a PERMANENT failure + * (not transient) because the outcome is deterministic -- retrying the same model just burns quota. + */ +export class UnparseableModelResponseError extends Error { + constructor(public readonly model: string, public readonly reason: string) { + super(`Model ${model} produced no reviewable output (${reason}); the file review failed.`); + this.name = 'UnparseableModelResponseError'; + } +} + export function providerErrorMessage(errorText: string) { try { const parsed = JSON.parse(errorText) as unknown; diff --git a/src/server/routes/api/dlq.ts b/src/server/routes/api/dlq.ts deleted file mode 100644 index 4fe4337..0000000 --- a/src/server/routes/api/dlq.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AppEnv } from '@server/env'; -import { jsonError } from '@server/core/http'; -import { logger } from '@server/core/logger'; - -/** - * Cloudflare Queues HTTP Pull API – DLQ bindings. - * - * The DLQ is a standard Cloudflare Queue named "codra-review-dlq". - * Because it is a pull queue (no Worker consumer), we interact with it via - * the Cloudflare REST API using the account-level API token. - * - * Endpoints surfaced here: - * GET /api/dlq – List pending DLQ messages (pull without acking) - * POST /api/dlq/:id/replay – Ack a DLQ message and re-enqueue its body on - * the main REVIEW_QUEUE for a fresh attempt. - * POST /api/dlq/purge – Ack all current DLQ messages (discard). - * - * Note: Cloudflare's pull-queue API requires a CF_API_TOKEN with - * Queues:Edit permissions. Add CF_API_TOKEN and CF_ACCOUNT_ID as - * Worker secrets, and CF_DLQ_ID as a required var (see .dev.vars.example). - */ - -const CF_QUEUES_BASE = 'https://api.cloudflare.com/client/v4'; - -/** Shape returned by the CF Queues pull endpoint. */ -type CfQueueMessage = { - lease_id: string; - body: unknown; - metadata: { - attempts: number; - timestamp: string; - }; -}; - -/** Tiny wrapper around the CF Queues pull-consumer HTTP API. */ -async function cfQueuesRequest( - method: 'GET' | 'POST' | 'DELETE', - path: string, - apiToken: string, - body?: unknown, -): Promise { - const url = `${CF_QUEUES_BASE}${path}`; - const response = await fetch(url, { - method, - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - body: body ? JSON.stringify(body) : undefined, - }); - - const data = (await response.json()) as { success: boolean; result?: unknown; errors?: unknown[] }; - - if (!response.ok || !data.success) { - throw new Error(`Cloudflare Queues API error (${response.status}): ${JSON.stringify(data.errors)}`); - } - - return data.result; -} - -export function createDlqRouter() { - const app = new Hono(); - - /** - * GET /api/dlq - * Pull up to `limit` messages from the DLQ without acking them. - * The response includes lease IDs needed for later replay/purge operations. - */ - app.get('/', async (c) => { - const { limit = '20' } = c.req.query(); - const batchSize = Math.min(Number(limit) || 20, 100); - - const apiToken = c.env.CF_API_TOKEN; - const accountId = c.env.CF_ACCOUNT_ID; - const dlqId = c.env.CF_DLQ_ID; - - if (!apiToken || !accountId || !dlqId) { - return jsonError('CF_API_TOKEN, CF_ACCOUNT_ID, and CF_DLQ_ID are required DLQ installation configuration for inspection.', 503); - } - - try { - const result = await cfQueuesRequest( - 'POST', - `/accounts/${accountId}/queues/${dlqId}/messages/pull`, - apiToken, - { batch_size: batchSize, visibility_timeout_ms: 30_000 }, - ) as { messages?: CfQueueMessage[] }; - - return c.json({ - messages: result.messages ?? [], - count: (result.messages ?? []).length, - }); - } catch (err) { - logger.error('DLQ pull failed', err instanceof Error ? err : new Error(String(err))); - return jsonError('Failed to pull DLQ messages.', 502); - } - }); - - /** - * POST /api/dlq/replay - * Body: { lease_ids: string[] } - * - * Acknowledges the specified DLQ messages (removes them from the DLQ) and - * re-enqueues their bodies on the main REVIEW_QUEUE so they get a fresh - * processing attempt with the latest Worker code. - * - * If a message body is not a valid ReviewJobMessage it is discarded with a - * warning rather than poisoning the main queue again. - */ - app.post('/replay', async (c) => { - const body = await z - .object({ lease_ids: z.array(z.string()).min(1).max(100) }) - .safeParseAsync(await c.req.json()); - - if (!body.success) { - return jsonError('lease_ids must be a non-empty array of strings.', 400); - } - - const apiToken = c.env.CF_API_TOKEN; - const accountId = c.env.CF_ACCOUNT_ID; - const dlqId = c.env.CF_DLQ_ID; - - if (!apiToken || !accountId || !dlqId) { - return jsonError('CF_API_TOKEN, CF_ACCOUNT_ID, and CF_DLQ_ID are required DLQ installation configuration for replay.', 503); - } - - // Step 1 – pull the specific messages so we have their bodies. - // CF does not expose a "get by lease_id" API; instead we pull a large - // batch, filter by the requested lease IDs, then ack+replay. - let pulled: CfQueueMessage[] = []; - try { - const result = await cfQueuesRequest( - 'POST', - `/accounts/${accountId}/queues/${dlqId}/messages/pull`, - apiToken, - { batch_size: 100, visibility_timeout_ms: 60_000 }, - ) as { messages?: CfQueueMessage[] }; - pulled = result.messages ?? []; - } catch (err) { - logger.error('DLQ pull for replay failed', err instanceof Error ? err : new Error(String(err))); - return jsonError('Failed to pull DLQ messages for replay.', 502); - } - - const requested = new Set(body.data.lease_ids); - const targets = pulled.filter((m) => requested.has(m.lease_id)); - const missing = body.data.lease_ids.filter((id) => !targets.find((m) => m.lease_id === id)); - - if (targets.length === 0) { - return jsonError('None of the requested lease IDs were found in the DLQ.', 404); - } - - // Step 2 – ack (delete) the targeted messages from the DLQ. - try { - await cfQueuesRequest( - 'POST', - `/accounts/${accountId}/queues/${dlqId}/messages/ack`, - apiToken, - { acks: targets.map((m) => ({ lease_id: m.lease_id })) }, - ); - } catch (err) { - logger.error('DLQ ack for replay failed', err instanceof Error ? err : new Error(String(err))); - return jsonError('Failed to ack DLQ messages before replay.', 502); - } - - // Step 3 – re-enqueue bodies on the main queue. - const replayed: string[] = []; - const discarded: string[] = []; - - for (const msg of targets) { - try { - await c.env.REVIEW_QUEUE.send(msg.body as any); - replayed.push(msg.lease_id); - logger.info('DLQ message replayed', { leaseId: msg.lease_id }); - } catch (err) { - discarded.push(msg.lease_id); - logger.error('DLQ replay enqueue failed', { leaseId: msg.lease_id, error: String(err) }); - } - } - - return c.json({ - replayed, - discarded, - missing, - replayedCount: replayed.length, - }, 202); - }); - - /** - * POST /api/dlq/purge - * Body: { lease_ids: string[] } - * - * Permanently discards the specified DLQ messages without replaying them. - * Useful for clearing known-bad messages that can never succeed. - */ - app.post('/purge', async (c) => { - const body = await z - .object({ lease_ids: z.array(z.string()).min(1).max(100) }) - .safeParseAsync(await c.req.json()); - - if (!body.success) { - return jsonError('lease_ids must be a non-empty array of strings.', 400); - } - - const apiToken = c.env.CF_API_TOKEN; - const accountId = c.env.CF_ACCOUNT_ID; - const dlqId = c.env.CF_DLQ_ID; - - if (!apiToken || !accountId || !dlqId) { - return jsonError('CF_API_TOKEN, CF_ACCOUNT_ID, and CF_DLQ_ID are required DLQ installation configuration for purge.', 503); - } - - try { - await cfQueuesRequest( - 'POST', - `/accounts/${accountId}/queues/${dlqId}/messages/ack`, - apiToken, - { acks: body.data.lease_ids.map((id) => ({ lease_id: id })) }, - ); - } catch (err) { - logger.error('DLQ purge failed', err instanceof Error ? err : new Error(String(err))); - return jsonError('Failed to purge DLQ messages.', 502); - } - - return c.json({ purged: body.data.lease_ids.length }); - }); - - return app; -} diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index e25fd35..ecd8a6a 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -2,10 +2,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 { bytesToHex, cancelJob, deleteJob, 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'; +import { logger } from '@server/core/logger'; +import type { AppBindings } from '@server/env'; + +/** + * Best-effort termination of a job's Cloudflare Workflow instance. The instance id is the one we + * stored (workflowInstanceId) or, as a fallback, the job id we passed to REVIEW_WORKFLOW.create(). + * .get() throws if the instance doesn't exist and .terminate() throws if it's already terminal -- + * both are non-fatal here (there's simply nothing left to stop). + */ +async function terminateJobWorkflow(env: AppBindings, job: { id: string; workflowInstanceId?: string | null }) { + const instanceId = job.workflowInstanceId ?? job.id; + try { + const instance = await env.REVIEW_WORKFLOW.get(instanceId); + await instance.terminate(); + } catch (error) { + logger.info(`Could not terminate workflow for job ${job.id} (already finished or never started)`, { + error: error instanceof Error ? error.message : String(error), + }); + } +} 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()}"`; @@ -59,11 +79,11 @@ export function createJobsRouter() { return response; }); - app.post('/:id/retry', async (c) => { - const rawSource = await getJobForProcessing(c.env, c.req.param('id')); - if (!rawSource) { - return jsonError('Job not found.', 404); - } + // Shared logic for "re-run" (retry) and "rerun from start". Creates a fresh job for the same PR, + // supersedes any older queued/running jobs, and enqueues the prepare phase. When inherit=true the + // new job links to its parent (retryOfJobId) and reuses already-`done` file reviews; when false + // it starts from scratch (no inheritance) so every file is reviewed again. + async function startReplacementJob(c: Context, rawSource: NonNullable>>, options: { inherit: boolean }) { const source = mapJob(rawSource); let configSnapshot; try { @@ -90,7 +110,7 @@ export function createJobsRouter() { headRef: rawSource.head_ref, baseRef: rawSource.base_ref, configSnapshot, - retryOfJobId: source.id, + ...(options.inherit ? { retryOfJobId: source.id } : {}), }); // Supersede any older pending/running jobs for this PR @@ -110,8 +130,65 @@ export function createJobsRouter() { requestId: c.get('requestId'), }); + return job; + } + + // Re-run: reuse the parent's already-completed file reviews where the model strategy still matches. + app.post('/:id/retry', async (c) => { + const rawSource = await getJobForProcessing(c.env, c.req.param('id')); + if (!rawSource) { + return jsonError('Job not found.', 404); + } + const job = await startReplacementJob(c, rawSource, { inherit: true }); return c.json({ job }, 202); }); + // Rerun from start: review every file again from scratch (no inheritance). Stops the current run + // first so two workflows don't race on the same PR. + app.post('/:id/rerun', async (c) => { + const rawSource = await getJobForProcessing(c.env, c.req.param('id')); + if (!rawSource) { + return jsonError('Job not found.', 404); + } + const source = mapJob(rawSource); + if (source.status === 'queued' || source.status === 'running') { + await terminateJobWorkflow(c.env, source); + } + const job = await startReplacementJob(c, rawSource, { inherit: false }); + return c.json({ job }, 202); + }); + + // Stop an ongoing job: terminate its workflow and mark it 'cancelled'. + app.post('/:id/stop', async (c) => { + const id = c.req.param('id'); + const raw = await getJobForProcessing(c.env, id); + if (!raw) { + return jsonError('Job not found.', 404); + } + const job = mapJob(raw); + if (job.status !== 'queued' && job.status !== 'running') { + return jsonError('Only a queued or running job can be stopped.', 409); + } + await terminateJobWorkflow(c.env, job); + await cancelJob(c.env, id); + const updated = await getJobForProcessing(c.env, id); + return c.json({ job: updated ? mapJob(updated) : job }, 200); + }); + + // Delete a job (cascades to its file reviews and comments). Stops the workflow first if running. + app.delete('/:id', async (c) => { + const id = c.req.param('id'); + const raw = await getJobForProcessing(c.env, id); + if (!raw) { + return jsonError('Job not found.', 404); + } + const job = mapJob(raw); + if (job.status === 'queued' || job.status === 'running') { + await terminateJobWorkflow(c.env, job); + } + await deleteJob(c.env, id); + return c.body(null, 204); + }); + return app; } diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index 8d8423e..897c291 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -28,7 +28,6 @@ import { ProviderRequestError } from '@server/models/types'; const apiFormatSchema = z.enum(llmApiFormats); const positiveIntegerSchema = z.number().int().positive().finite(); -const optionalLimitSchema = positiveIntegerSchema.nullable(); const modelIdSchema = z.string().trim().min(1); const optionalUrlSchema = z.string().trim().url().nullable().optional(); const providerIdSchema = z.string().uuid(); @@ -48,9 +47,6 @@ const providerUpdateSchema = providerCreateSchema.extend({ const modelConfigUpdateSchema = z.object({ providerId: providerIdSchema, modelName: z.string().trim().min(1), - rpm: optionalLimitSchema, - tpm: optionalLimitSchema, - rpd: optionalLimitSchema, }).strict(); const globalModelConfigSchema = z.object({ diff --git a/src/server/routes/api/repos.ts b/src/server/routes/api/repos.ts index 3b08c11..f5c98e7 100644 --- a/src/server/routes/api/repos.ts +++ b/src/server/routes/api/repos.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '@server/env'; -import { getRepoConfigRecord, listRepoConfigs, upsertRepoConfig, syncRepoConfig, updateRepoConfigEnabled } from '@server/db/repo-configs'; +import { getRepoConfigRecord, listRepoConfigs, upsertRepoConfig, syncRepoConfig, updateRepoConfigEnabled, deleteStaleRepoConfigs } from '@server/db/repo-configs'; import { jsonError } from '@server/core/http'; import { GitHubClient, type GitHubRepository } from '@server/core/github'; import { invalidateRepoConfigCache } from '@server/core/config'; @@ -83,9 +83,15 @@ export function createReposRouter() { }, ); + const installationSynced: string[] = []; for (const res of results) { - if (res) synced.push(res); + if (res) { + synced.push(res); + installationSynced.push(res); + } } + + await deleteStaleRepoConfigs(c.env, String(inst.id), installationSynced); } return c.json({ ok: true, synced }); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts new file mode 100644 index 0000000..774ffdc --- /dev/null +++ b/src/server/routes/api/settings.ts @@ -0,0 +1,41 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { AppEnv } from '@server/env'; +import { getReviewSettings, updateReviewSettings } from '@server/db/app-settings'; +import { jsonError } from '@server/core/http'; +import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewSettingsSchema } from '@shared/schema'; + +const reviewSettingsPatchSchema = z.object({ + concurrencyLevel: z.enum(reviewConcurrencyLevels).optional(), + maxComments: z.number().int().refine( + (value) => (reviewMaxCommentsOptions as readonly number[]).includes(value), + 'Invalid max comments value.', + ).optional(), +}).strict().refine( + (settings) => settings.concurrencyLevel !== undefined || settings.maxComments !== undefined, + 'At least one setting must be provided.', +); + +export function createSettingsRouter() { + const app = new Hono(); + + app.get('/', async (c) => { + const settings = await getReviewSettings(c.env); + return c.json({ settings }); + }); + + app.patch('/', async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = reviewSettingsPatchSchema.safeParse(body); + if (!parsed.success) { + return jsonError('Invalid review settings.', 400); + } + + const current = await getReviewSettings(c.env); + const next = reviewSettingsSchema.parse({ ...current, ...parsed.data }); + await updateReviewSettings(c.env, next); + return c.json({ ok: true, settings: next }); + }); + + return app; +} diff --git a/src/server/services/github.ts b/src/server/services/github.ts index f8e9150..21da477 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -20,7 +20,7 @@ export class GitHubService { return this.client.createCheckRun(owner, repo, params); } - async updateCheckRun(owner: string, repo: string, checkRunId: number, params: { title: string; summary: string; status?: 'in_progress' | 'completed'; conclusion?: 'success' | 'neutral' | 'failure' }) { + async updateCheckRun(owner: string, repo: string, checkRunId: number, params: { title: string; summary: string; status?: 'in_progress' | 'completed'; conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled' }) { return this.client.updateCheckRun(owner, repo, checkRunId, params); } @@ -28,6 +28,10 @@ export class GitHubService { return this.client.createReview(owner, repo, prNumber, params); } + async findBotReviewForCommit(owner: string, repo: string, prNumber: number, commitSha: string, botLogin: string) { + return this.client.findBotReviewForCommit(owner, repo, prNumber, commitSha, botLogin); + } + async ensureLabel(owner: string, repo: string, name: string, color: string) { return this.client.ensureLabel(owner, repo, name, color); } diff --git a/src/server/services/model.ts b/src/server/services/model.ts index 144a9a2..46a83a3 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -1,19 +1,21 @@ import type { AppBindings } from '../env'; import { reviewWithGoogle } from '../models/google'; -import { reviewWithCloudflare } from '../models/cloudflare'; +import { reviewWithCloudflare, submitCloudflareBatch, pollCloudflareBatch } 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 { truncateFileDiff, chunkFileDiff } from '../core/diff'; import type { RepoConfig } from '@shared/schema'; import type { TokenTracker } from '../core/token-tracker'; -import type { ModelResponse } from '../models/types'; +import { UnparseableModelResponseError, type ModelResponse } from '../models/types'; import { logger } from '../core/logger'; import { normalizeModelId } from '@shared/schema'; +import { isTimeoutMessage, matchesAnyTransientSubstring } from '@shared/transient-errors'; import { getResolvedModelConfig, type ResolvedModelConfig } from '@server/db/model-configs'; import { decryptLlmApiKey } from '@server/core/llm-crypto'; +import { ModelCallGate, adaptiveModelTimeoutMs, MODEL_FALLBACK_CHAIN_BUDGET_MS } from '../models/limits'; const PROVIDER_UNAVAILABLE_TTL_SECONDS = 24 * 60 * 60; const COMPACT_REVIEW_PROMPT_LINE_CAP = 400; @@ -57,33 +59,74 @@ function isCloudflareAllocationError(error: unknown) { function isGoogleRateLimitError(error: unknown) { const message = error instanceof Error ? error.message : String(error); - return message.includes('429') || message.includes('RESOURCE_EXHAUSTED') || message.toLowerCase().includes('quota exceeded'); + const lower = message.toLowerCase(); + + if (lower.includes('timed out') || lower.includes('timeout')) { + return false; + } + + return lower.includes('429') || lower.includes('resource_exhausted') || lower.includes('quota exceeded'); } function isTransientModelFailure(error: unknown) { if (isRetryableModelError(error)) return true; + // No reviewable output (reasoning-only / truncated / empty) is deterministic -- never retry it. + if (error instanceof UnparseableModelResponseError) return false; if (isCloudflareAllocationError(error)) return false; const message = error instanceof Error ? error.message : String(error); const lower = message.toLowerCase(); + // Explicitly fail fast for timeouts so they don't loop endlessly + if (isTimeoutMessage(lower)) { + return false; + } + 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') || + matchesAnyTransientSubstring(lower) || lower.includes('fetch failed') || lower.includes('network') || lower.includes('temporar') || - lower.includes('returned no review content') || - lower.includes('empty response') || - lower.includes('[redacted]') + // Upstream 5xx (e.g. Gemini's frequent "request failed with 500: Internal error encountered") + // is a transient server-side outage, not a deterministic client error. Without this a sustained + // 5xx run makes every model in the chain throw a non-transient error, so the file is marked + // permanently failed instead of being deferred and retried once the provider recovers. + /\b50[0-9]\b/.test(lower) || + lower.includes('internal error') ); } export class ModelService { + // Model configs don't change during a single review invocation, but resolveModel() is called + // once per file *and* once per fallback model. Left uncached that's a Hyperdrive round-trip + // (a counted subrequest) for every one of those, which both burns the per-invocation + // subrequest budget (shrinking how many files a chunk can review in parallel) and floods the + // connection pool. Memoize per ModelService instance (one instance == one invocation/chunk) + // so each distinct model is resolved from the DB at most once. Cache the in-flight promise + // (not just the settled value) so concurrent resolveModel() calls for the same model made + // before the first DB round-trip completes all await the same request instead of each firing + // their own. + private readonly resolvedModelCache = new Map>(); + + // The Workers runtime allows only 6 simultaneous connections per invocation; anything beyond + // that is queued without starting. When several files review in parallel, un-gated model + // calls queue behind each other and burn their entire client timeout before the request is + // even dispatched (observed as a provider "timing out" at exactly the configured timeout on + // every attempt). Gate all outbound model calls for this invocation so a call's timeout only + // starts once it actually has a connection slot. + private readonly callGate = new ModelCallGate(); + + // Provider-unavailable markers live in KV and every read is a counted subrequest. The marker + // can't flip from set back to unset within one invocation, so cache lookups per instance + // (one instance == one invocation) instead of re-reading KV for every file in the chunk. + private readonly providerUnavailableCache = new Map>(); + + // Models proven (this invocation) not to support the async batch queue. try-async-then-fallback + // means the first file probes async; if that fails, every later file in the same chunk skips the + // probe and goes straight to the synchronous path, so a non-async model isn't charged an extra + // (potentially full-inference) submit attempt per file. + private readonly asyncUnsupportedModels = new Set(); + constructor( private env: AppBindings, private tracker?: TokenTracker, @@ -94,25 +137,37 @@ export class ModelService { return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${providerId}` : null; } - private async isProviderUnavailable(providerId: string) { + private isProviderUnavailable(providerId: string): Promise { const key = this.providerUnavailableKey(providerId); - if (!key) return false; + if (!key) return Promise.resolve(false); - try { - return (await this.env.APP_KV.get(key)) !== null; - } catch (error) { - logger.warn(`Failed to read unavailable provider marker for ${providerId}`, { - error: error instanceof Error ? error.message : String(error), - }); - return false; + let pending = this.providerUnavailableCache.get(providerId); + if (!pending) { + pending = (async () => { + try { + this.tracker?.incrementSubrequests(1); + return (await this.env.APP_KV.get(key)) !== null; + } catch (error) { + logger.warn(`Failed to read unavailable provider marker for ${providerId}`, { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + })(); + this.providerUnavailableCache.set(providerId, pending); } + return pending; } private async markProviderUnavailable(providerId: string, reason: string) { const key = this.providerUnavailableKey(providerId); if (!key) return; + // Keep the in-invocation cache consistent with what we just wrote. + this.providerUnavailableCache.set(providerId, Promise.resolve(true)); + try { + this.tracker?.incrementSubrequests(1); await this.env.APP_KV.put( key, JSON.stringify({ @@ -161,7 +216,17 @@ export class ModelService { private async resolveModel(model: string) { const normalized = normalizeModel(model); - const resolved = await getResolvedModelConfig(this.env, normalized); + let pending = this.resolvedModelCache.get(normalized); + if (!pending) { + // Cache the DB answer -- including a null "not configured" result -- so a missing or + // repeatedly-used model isn't re-queried for every file in the chunk. + pending = getResolvedModelConfig(this.env, normalized); + this.resolvedModelCache.set(normalized, pending); + // A failed lookup (e.g. a transient DB error) shouldn't poison the cache for the rest of + // the invocation -- drop it so the next call retries instead of rejecting immediately. + pending.catch(() => this.resolvedModelCache.delete(normalized)); + } + const resolved = await pending; if (!resolved) { throw new Error(`Model ${normalized} is not configured. Add it in Settings before using it in a route.`); } @@ -183,43 +248,59 @@ export class ModelService { private async callResolvedModel( config: ResolvedModelConfig, input: { systemPrompt: string; userPrompt: string }, + timeoutMs?: number, ): Promise { + // Resolve credentials *before* taking a gate slot so slow KV/crypto work never occupies a + // model-call slot, then run the actual provider request under the gate. The provider's + // timeout only starts inside the gated call, so time spent waiting for a slot is free. if (config.apiFormat === 'cloudflare-workers-ai') { - return reviewWithCloudflare(this.env, config.modelName, input, this.tracker, config.providerName); + return this.callGate.run(() => + reviewWithCloudflare(this.env, config.modelName, input, this.tracker, config.providerName, { timeoutMs }), + ); } if (config.apiFormat === 'gemini') { - return reviewWithGoogle( - { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName }, - config.modelName, - input, - this.tracker, + const apiKey = await this.decryptApiKey(config); + return this.callGate.run(() => + reviewWithGoogle( + { apiKey, baseUrl: config.baseUrl, providerName: config.providerName, timeoutMs }, + config.modelName, + input, + this.tracker, + ), ); } if (config.apiFormat === 'openai') { - return reviewWithOpenAI( - { - apiKey: await this.decryptApiKey(config), - baseUrl: config.baseUrl || 'https://api.openai.com/v1', - providerName: config.providerName, - }, - config.modelName, - input, - this.tracker, + const apiKey = await this.decryptApiKey(config); + return this.callGate.run(() => + reviewWithOpenAI( + { + apiKey, + baseUrl: config.baseUrl || 'https://api.openai.com/v1', + providerName: config.providerName, + timeoutMs, + }, + config.modelName, + input, + this.tracker, + ), ); } - return reviewWithAnthropic( - { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName }, - config.modelName, - input, - this.tracker, + const apiKey = await this.decryptApiKey(config); + return this.callGate.run(() => + reviewWithAnthropic( + { apiKey, baseUrl: config.baseUrl, providerName: config.providerName, timeoutMs }, + config.modelName, + input, + this.tracker, + ), ); } - private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }): Promise { - return this.callResolvedModel(await this.resolveModel(model), input); + private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }, timeoutMs?: number): Promise { + return this.callResolvedModel(await this.resolveModel(model), input, timeoutMs); } async reviewFile(params: { @@ -234,10 +315,170 @@ export class ModelService { const modelLineCap = params.compactPrompt ? Math.min(configuredLineCap, COMPACT_REVIEW_PROMPT_LINE_CAP) : configuredLineCap; - const reviewFile = truncateFileDiff(params.file, modelLineCap); + + let chunks = chunkFileDiff(params.file, modelLineCap); + // Remember the pre-cap chunk count so wasPromptTruncated doesn't have to re-run chunkFileDiff. + const totalChunkCount = chunks.length; + + // Cap chunks to prevent single files from burning all subrequests and getting stuck. + const MAX_CHUNKS = 4; + if (chunks.length > MAX_CHUNKS) { + chunks = chunks.slice(0, MAX_CHUNKS); + } + + if (chunks.length === 1) { + return this.reviewFileChunk({ ...params, file: chunks[0] }); + } + + const results: Array, reviewedLineCount: number, wasPromptTruncated: boolean, userPrompt: string }> = []; + + for (const chunk of chunks) { + // Don't start a new chunk if we are dangerously close to the 50 subrequest limit. + if (results.length > 0 && this.tracker?.isNearLimit()) { + logger.warn(`Stopping chunk processing for ${params.file.path} early due to subrequest budget limits.`); + break; + } + + try { + const res = await this.reviewFileChunk({ ...params, file: chunk }); + results.push(res as any); + } catch (error) { + if (results.length === 0) { + throw error; // First chunk failed, let it defer/fail properly + } + logger.warn(`Chunk review failed for ${params.file.path}, returning partial results to avoid stalling the job.`, { error: error instanceof Error ? error.message : String(error) }); + break; + } + } + + const combinedFindings = results.flatMap(r => r.parsed.comments); + // Report the file with the most serious chunk's verdict/summary/correctness, not just the last + // chunk's: taking `results[results.length - 1]` would let a clean final chunk mask real findings + // from an earlier chunk of the same file (reporting verdict 'approve' while carrying its comments). + const primaryResult = results.find(r => r.parsed.verdict === 'comment') ?? results[results.length - 1]; + + return { + ...primaryResult, + inputTokens: results.reduce((sum, r) => sum + r.inputTokens, 0), + outputTokens: results.reduce((sum, r) => sum + r.outputTokens, 0), + parsed: { + ...primaryResult.parsed, + comments: combinedFindings, + }, + reviewedLineCount: results.reduce((sum, r) => sum + r.reviewedLineCount, 0), + wasPromptTruncated: chunks.length < totalChunkCount || results.length < chunks.length, + }; + } + + /** + * Try to submit a file's review to the Workers AI asynchronous batch queue. Returns the queue + * request_id and the model it was submitted to, or null when async batching isn't usable for + * the primary model (non-Cloudflare provider, or the model/account doesn't support queueing) -- + * in which case the caller falls back to the synchronous reviewFile path. This decouples slow + * (e.g. reasoning) model inference from the per-invocation timeout and subrequest cap. + */ + async submitReviewBatch(params: { + file: any; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + }): Promise<{ requestId: string; model: string } | null> { + const { primary } = this.selectModel({ totalLineCount: params.totalLineCount, config: params.config }); + + let resolved: ResolvedModelConfig; + try { + resolved = await this.resolveModel(primary); + } catch { + return null; + } + // Only Cloudflare Workers AI exposes the async batch queue; other providers use the sync path. + if (resolved.apiFormat !== 'cloudflare-workers-ai') return null; + // Skip the probe for a model already shown not to support async queueing this invocation. + if (this.asyncUnsupportedModels.has(resolved.modelName)) return null; + + const configuredLineCap = params.config.review.max_diff_lines_per_file; + const modelLineCap = params.compactPrompt + ? Math.min(configuredLineCap, COMPACT_REVIEW_PROMPT_LINE_CAP) + : configuredLineCap; + const file = truncateFileDiff(params.file, modelLineCap); const { systemPrompt, userPrompt } = buildFileReviewPrompts({ ...params, - file: reviewFile, + file, + config: params.config.review, + }); + + try { + const requestId = await this.callGate.run(() => + submitCloudflareBatch(this.env, resolved.modelName, { systemPrompt, userPrompt }, this.tracker), + ); + return { requestId, model: resolved.modelName }; + } catch (error) { + // Any failure here (async unsupported, transient submit error) is non-fatal: the caller + // reviews the file synchronously instead. Remember the model so sibling files this + // invocation don't each pay the failed probe. + this.asyncUnsupportedModels.add(resolved.modelName); + logger.warn(`Async batch submit unavailable for ${resolved.modelName}; using synchronous review`, { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + /** + * Poll a previously submitted async batch review. Returns 'pending' while still queued/running, + * 'done' with the parsed review once complete, or 'failed' if the poll or parse errored. + */ + async pollReviewBatch(params: { model: string; requestId: string; file: any }): Promise< + | { status: 'pending' } + | { status: 'done'; response: ModelResponse & { parsed: ReturnType; reviewedLineCount: number; wasPromptTruncated: boolean; userPrompt: string } } + | { status: 'failed'; error: unknown } + > { + let resolved: ResolvedModelConfig; + try { + resolved = await this.resolveModel(params.model); + } catch (error) { + return { status: 'failed', error }; + } + + try { + const poll = await this.callGate.run(() => + pollCloudflareBatch(this.env, resolved.modelName, params.requestId, this.tracker, resolved.providerName), + ); + if (poll.status === 'pending') return { status: 'pending' }; + + const response = poll.response; + if (this.tracker) { + this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens); + } + const parsed = parseFileReviewResponse(response.rawText, params.file); + return { + status: 'done', + response: { + ...response, + parsed, + userPrompt: '', + reviewedLineCount: params.file.lineCount, + wasPromptTruncated: params.file.isTruncated === true, + }, + }; + } catch (error) { + return { status: 'failed', error }; + } + } + + private async reviewFileChunk(params: { + file: any; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + }) { + const { systemPrompt, userPrompt } = buildFileReviewPrompts({ + ...params, + file: params.file, config: params.config.review, }); @@ -247,10 +488,52 @@ export class ModelService { }); const modelsToTry = [primary, ...fallbacks]; + // Size the per-call timeout to the diff the model actually sees: small + // files fail over to the next model fast; large diffs get a proportionally longer budget. + const timeoutMs = adaptiveModelTimeoutMs(params.file.lineCount); + let lastError: unknown; let lastTransientError: unknown; let sawTransientFailure = false; - for (const currentModel of modelsToTry) { + const chainStartedAt = Date.now(); + for (const [modelIndex, currentModel] of modelsToTry.entries()) { + // Always allow the first (primary) model a shot even if the shared job budget is + // already tight, so a file isn't punished for other files' earlier failures. But once + // we're into the fallback chain, each additional attempt costs more subrequests + // (config lookup + provider call, sometimes a provider-availability check too) that + // could tip this whole invocation over Cloudflare's per-invocation subrequest cap + // (Workers Free plan: 50). Defer the file for a later retry instead of gambling the + // rest of the invocation's budget on a low-probability extra fallback. + if (modelIndex > 0 && this.tracker?.isNearLimit()) { + logger.warn(`Skipping remaining fallback models for ${params.file.path}; subrequest budget for this invocation is nearly exhausted`, { + skippedModels: modelsToTry.slice(modelIndex), + }); + + // If we haven't seen any transient failures (e.g. they were all permanent timeouts), + // don't force this to become a transient failure. Just break and let the last permanent error propagate. + if (sawTransientFailure) { + lastTransientError = lastTransientError ?? lastError ?? new Error('Subrequest budget for this invocation was nearly exhausted before trying all configured fallback models'); + } + break; + } + + // Stop walking the fallback chain once this file has consumed its wall-clock budget: a long + // chain of slow/timing-out models could otherwise run several calls back-to-back and push the + // whole workflow invocation past Cloudflare's ~120s limit (killing it as `exceededCpu` and + // losing all progress). Defer instead -- the file resumes from the fast primary model in a + // fresh invocation. Always let the primary (modelIndex 0) run first. + if (modelIndex > 0 && Date.now() - chainStartedAt > MODEL_FALLBACK_CHAIN_BUDGET_MS) { + logger.warn(`Deferring ${params.file.path}: fallback chain exceeded its per-invocation time budget`, { + elapsedMs: Date.now() - chainStartedAt, + skippedModels: modelsToTry.slice(modelIndex), + }); + // Treat as a transient/deferrable outcome so the file is retried on a fresh budget rather + // than marked permanently failed. + sawTransientFailure = true; + lastTransientError = lastTransientError ?? lastError ?? new Error(`Model fallback chain for ${params.file.path} exceeded its time budget; deferring for retry.`); + break; + } + let resolved: ResolvedModelConfig; try { resolved = await this.resolveModel(currentModel); @@ -267,52 +550,40 @@ export class ModelService { continue; } - let attempts = 0; - const maxAttempts = 1; + // One shot per model: a failed call is never retried against the same model (a retryable + // outage is handled by deferring the whole file to a fresh invocation), so on failure we just + // fall through to the next model in the fallback chain. + try { + const response = await this.callResolvedModel(resolved, { systemPrompt, userPrompt }, timeoutMs); - while (attempts < maxAttempts) { - try { - const response = await this.callResolvedModel(resolved, { systemPrompt, userPrompt }); - - if (this.tracker) { - this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens); - } - - const parsed = parseFileReviewResponse(response.rawText, params.file); - return { - ...response, - parsed, - userPrompt, - reviewedLineCount: reviewFile.lineCount, - wasPromptTruncated: reviewFile.isTruncated === true, - }; - } catch (error) { - lastError = error; - if (isTransientModelFailure(error)) { - sawTransientFailure = true; - lastTransientError = error; - } - attempts++; - 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: errorMessage, - rateLimited: isRateLimit, - willRetrySameModel: isRetryable, - willTryFallback: !isRetryable && modelsToTry.indexOf(currentModel) < modelsToTry.length - 1 - }); + if (this.tracker) { + this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens); + } - if (isRetryable) { - continue; - } - break; // Move to next model in fallbacks + const parsed = parseFileReviewResponse(response.rawText, params.file); + return { + ...response, + parsed, + userPrompt, + reviewedLineCount: params.file.lineCount, + wasPromptTruncated: params.file.isTruncated === true, + }; + } catch (error) { + lastError = error; + if (isTransientModelFailure(error)) { + sawTransientFailure = true; + lastTransientError = error; } + if (resolved.apiFormat === 'cloudflare-workers-ai' && isCloudflareAllocationError(error)) { + await this.markProviderUnavailable(resolved.providerId, error instanceof Error ? error.message : String(error)); + } + + logger.warn(`Model ${currentModel} failed for ${params.file.path}`, { + error: error instanceof Error ? error.message : String(error), + rateLimited: isGoogleRateLimitError(error), + willTryFallback: modelIndex < modelsToTry.length - 1, + }); + // Fall through to the next model in the fallback chain. } } @@ -361,7 +632,7 @@ export class ModelService { const response = await this.callResolvedModel(resolved, { systemPrompt: SUMMARY_SYSTEM_PROMPT, userPrompt: buildSummaryPrompt(params), - }); + }, adaptiveModelTimeoutMs(0)); if (this.tracker) { this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens); diff --git a/src/server/worker-env.d.ts b/src/server/worker-env.d.ts index 1a20668..048eaa0 100644 --- a/src/server/worker-env.d.ts +++ b/src/server/worker-env.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -// 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 +// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 76d8ec86de20e8aacd6c22180ec1b53d) +// Runtime types generated with workerd@1.20260625.1 2026-04-16 nodejs_compat interface __BaseEnv_Env { APP_KV: KVNamespace; HYPERDRIVE: Hyperdrive; @@ -13,7 +13,6 @@ interface __BaseEnv_Env { 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; @@ -22,6 +21,7 @@ interface __BaseEnv_Env { LLM_CONFIG_ENCRYPTION_KEY: string; CF_API_TOKEN: string; CF_ACCOUNT_ID: string; + REVIEW_WORKFLOW: Workflow[0]['payload']>; } declare namespace Cloudflare { interface GlobalProps { @@ -34,7 +34,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types @@ -460,7 +460,8 @@ interface ExecutionContext { readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; - tracing?: Tracing; + readonly access?: CloudflareAccessContext; + tracing: Tracing; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -516,6 +517,10 @@ interface CachePurgeOptions { interface CacheContext { purge(options: CachePurgeOptions): Promise; } +interface CloudflareAccessContext { + readonly aud: string; + getIdentity(): Promise; +} declare abstract class ColoLocalActorNamespace { get(actorId: string): Fetcher; } @@ -549,7 +554,7 @@ type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } -type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me"; type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; @@ -645,6 +650,7 @@ interface DurableObjectFacets { get(name: string, getStartupOptions: () => FacetStartupOptions | Promise>): Fetcher; abort(name: string, reason: any): void; delete(name: string): void; + clone(src: string, dst: string): void; } interface FacetStartupOptions { id?: DurableObjectId | string; @@ -3320,6 +3326,28 @@ interface EventSourceEventSourceInit { withCredentials?: boolean; fetcher?: Fetcher; } +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record; + user?: string; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly exitCode: Promise; + output(): Promise; + kill(signal?: number): void; +} interface Container { get running(): boolean; start(options?: ContainerStartupOptions): void; @@ -3333,6 +3361,7 @@ interface Container { snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; snapshotContainer(options: ContainerSnapshotOptions): Promise; interceptOutboundHttps(addr: string, binding: Fetcher): Promise; + exec(cmd: string[], options?: ContainerExecOptions): Promise; } interface ContainerDirectorySnapshot { id: string; @@ -3503,11 +3532,281 @@ declare abstract class Performance { } interface Tracing { enterSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + startActiveSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; Span: typeof Span; } declare abstract class Span { get isTraced(): boolean; setAttribute(key: string, value?: (boolean | number | string)): void; + end(): void; +} +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} +// ============================================================================ +// Agent Memory +// +// Public type surface for user Workers binding to an Agent Memory namespace. +// ============================================================================ +/** Memory type — every memory is classified into exactly one. */ +type AgentMemoryMemoryType = "fact" | "event" | "instruction" | "task"; +/** Search intensity for recall. */ +type AgentMemoryThinkingLevel = "low" | "medium" | "high"; +/** Response verbosity for recall. */ +type AgentMemoryResponseLength = "short" | "medium" | "long"; +/** A conversation message passed to ingest(). */ +interface AgentMemoryMessage { + role: "system" | "user" | "assistant"; + content: string; + /** Optional message timestamp. */ + timestamp?: Date; +} +/** Raw memory content passed to remember(). */ +interface AgentMemoryIncomingMemory { + /** Raw memory content. The service classifies and summarizes automatically. */ + content: string; + /** Optional session identifier to associate with this memory. */ + sessionId?: string | null | undefined; +} +/** A stored memory returned from remember(), get(), and delete(). */ +interface AgentMemoryMemory { + /** Memory ID. */ + id: string; + /** Memory type. */ + type: AgentMemoryMemoryType; + /** Text summary. */ + summary: string; + /** Memory text. */ + content: string; + /** Session that created this memory. */ + sessionId: string | null; + /** Memory creation time. */ + createdAt: Date; + /** Memory last-update time. */ + updatedAt: Date; +} +/** Single entry in a list() response. Same shape as Memory minus full content. */ +type AgentMemoryMemoryListEntry = Omit; +/** A scored memory candidate in a recall result. */ +interface AgentMemoryScoredCandidate { + /** Candidate ID. */ + id: string; + /** Text summary. */ + summary: string; + /** Session that created this candidate, when known. */ + sessionId: string | null; + /** Relevance score (higher is better). Comparable only within a single query. */ + score: number; +} +/** Options for the ingest() method. */ +interface AgentMemoryIngestOptions { + /** Session identifier to associate with memories created during ingestion. */ + sessionId?: string | null | undefined; +} +/** Options for the getSummary() method. */ +interface AgentMemoryGetSummaryOptions { + /** Session identifier to retrieve session summary for. */ + sessionId?: string | null | undefined; +} +/** Response from the getSummary() method. */ +interface AgentMemoryGetSummaryResponse { + /** Markdown summary. */ + summary: string; +} +/** + * Options for the recall() method. + * + * `referenceDate` accepts a Date object, an ISO-8601 date string + * (YYYY-MM-DD), or a full ISO-8601 datetime string. When provided, this + * date is used as "today" for resolving relative time references + * ("how many days ago", "last week") instead of the server's wall-clock time. + */ +interface AgentMemoryRecallOptions { + /** Recall intensity: "low" (default), "medium", or "high". */ + thinkingLevel?: AgentMemoryThinkingLevel; + /** Response verbosity: "short", "medium" (default), or "long". */ + responseLength?: AgentMemoryResponseLength; + /** Temporal anchor for date arithmetic. */ + referenceDate?: Date | string; +} +/** Response from the recall() method. */ +interface AgentMemoryRecallResult { + /** Number of memories retrieved. */ + count: number; + /** LLM-generated answer synthesizing the matching memories. */ + answer: string; + /** Matching memories ranked by relevance. */ + candidates: AgentMemoryScoredCandidate[]; +} +/** + * Options for the list() method. + * + * `cursor` is the opaque continuation token returned by the previous page; + * pass it back unchanged to fetch the next page. `sessionId` and `type` + * are exact-match filters; combining them is allowed. + */ +interface AgentMemoryListMemoriesOptions { + /** Maximum number of memories to return. Default 20, max 500. */ + limit?: number; + /** Opaque cursor from a previous page. */ + cursor?: string; + /** Exact-match session filter. */ + sessionId?: string; + /** Exact-match memory-type filter. */ + type?: AgentMemoryMemoryType; +} +/** Response from the list() method. */ +interface AgentMemoryListMemoriesResult { + memories: AgentMemoryMemoryListEntry[]; + /** Continuation cursor; absent when this page exhausted the result set. */ + cursor?: string; +} +/** + * A single Agent Memory profile, scoped to a profile name. + * + * Returned by {@link AgentMemoryNamespace.getProfile}. + */ +declare abstract class AgentMemoryProfile { + /** + * Retrieve a memory by ID. + * + * @param memoryId - ULID of the memory to retrieve. + * @throws if the memory does not exist. + */ + get(memoryId: string): Promise; + /** + * Delete a memory by ID. + * + * Removes the memory and any source messages linked by the memory's + * source message IDs. + * + * @param memoryId - ULID of the memory to delete. + * @throws if the memory does not exist. + */ + delete(memoryId: string): Promise; + /** + * Store a memory in this profile. The content is automatically classified, + * summarized, and indexed. + * + * @param memory - Raw memory content to persist. + */ + remember(memory: AgentMemoryIncomingMemory): Promise; + /** + * Extract memories from a conversation. + * + * @param messages - Conversation messages to extract memories from. + * @param options - Optional ingest options. + */ + ingest(messages: Iterable, options?: AgentMemoryIngestOptions): Promise; + /** + * Get a profile summary. + * + * @param options - Optional getSummary options. + */ + getSummary(options?: AgentMemoryGetSummaryOptions): Promise; + /** + * Recall memories in this profile. + * + * @param query - Recall query matched against memory content and keywords. + * @param options - Optional recall parameters. + * @returns Matching memories with relevance scores and a synthesized answer. + */ + recall(query: string, options?: AgentMemoryRecallOptions): Promise; + /** + * List active memories in this profile. + * + * Returns a paginated, filterable view of stored memories. Superseded + * versions are excluded. Use the returned `cursor` (when present) to + * fetch the next page. + * + * @param options - Optional pagination and filter options. + */ + list(options?: AgentMemoryListMemoriesOptions): Promise; + /** + * Soft-delete every memory and message in this profile that is tagged + * with `sessionId`. + * + * Idempotent: deleting a sessionId that has no rows is a no-op. + * + * @param sessionId - Session to delete. + */ + deleteSession(sessionId: string): Promise; +} +/** + * Namespace-level Agent Memory binding. + * + * Used as the type of an `env.MEMORY`-style binding backed by the Agent + * Memory product. + * + * @example + * ```ts + * export default { + * async fetch(_request: Request, env: Env): Promise { + * const profile = await env.MEMORY.getProfile("wrangler-e2e"); + * const summary = await profile.getSummary(); + * return Response.json(summary); + * }, + * }; + * ``` + */ +declare abstract class AgentMemoryNamespace { + /** + * Get a memory profile by name. Profiles are isolated by namespace and + * addressed by a compound key (namespaceId:profileName). + * + * @param profileName - Profile name (validated against naming rules). + * @returns RPC target for interacting with the profile. + */ + getProfile(profileName: string): Promise; + /** + * Soft-delete a profile and schedule deferred purge. Marks all + * memories and messages as deleted. + * + * @param profileName - Name of the profile to delete. + */ + deleteProfile(profileName: string): Promise; } // ============ AI Search Error Interfaces ============ interface AiSearchInternalError extends Error { @@ -4854,9 +5153,6 @@ type ChatCompletionChoice = { finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | "function_call"; logprobs: ChatCompletionLogprobs | null; }; -type ChatCompletionsPromptInput = { - prompt: string; -} & ChatCompletionsCommonOptions; type ChatCompletionsMessagesInput = { messages: Array; } & ChatCompletionsCommonOptions; @@ -8786,11 +9082,11 @@ declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 { postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output; } declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B { - inputs: XOR; + inputs: XOR; postProcessedOutputs: XOR; } declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B { - inputs: XOR; + inputs: XOR; postProcessedOutputs: XOR; } interface Ai_Cf_Leonardo_Phoenix_1_0_Input { @@ -9766,6 +10062,10 @@ declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_5 { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; } +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_6 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; @@ -9863,7 +10163,9 @@ interface AiModels { "@cf/black-forest-labs/flux-2-klein-9b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B; "@cf/zai-org/glm-4.7-flash": Base_Ai_Cf_Zai_Org_Glm_4_7_Flash; "@cf/moonshotai/kimi-k2.5": Base_Ai_Cf_Moonshotai_Kimi_K2_5; + "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; + "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; } type AiOptions = { /** @@ -9916,10 +10218,8 @@ type AiModelsSearchObject = { value: string; }[]; }; -type ChatCompletionsBase = XOR; -type ChatCompletionsInput = XOR; +type ChatCompletionsBase = ChatCompletionsMessagesInput; +type ChatCompletionsInput = ChatCompletionsMessagesInput; interface InferenceUpstreamError extends Error { } interface AiInternalError extends Error { @@ -9964,8 +10264,15 @@ declare abstract class Ai { }, options?: AiOptions): Promise; // Normal (default) - known model run(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise; - // Unknown model (gateway fallback) - run(model: string & {}, inputs: Record, options?: AiOptions): Promise>; + // Unknown model (fallback). + // + // The `Exclude<..., keyof AiModelList>` constraint forces TypeScript to + // route any model name that is a literal key of `AiModelList` to one of + // the known-model overloads above (so input/output mismatches surface as + // type errors rather than silently falling back to `Record`). + // Names that aren't in `AiModelList` — e.g. third-party gateway models + // like `"google/nano-banana"` — still hit this overload. + run(model: Model extends keyof AiModelList ? never : Model, inputs: Record, options?: AiOptions): Promise>; models(params?: AiModelsSearchParams): Promise; toMarkdown(): ToMarkdownService; toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; @@ -10423,77 +10730,451 @@ declare abstract class AutoRAG { */ aiSearch(params: AutoRagAiSearchRequest): Promise; } -interface BasicImageTransformations { +type BrowserRunLifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; +type BrowserRunResourceType = 'document' | 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'texttrack' | 'xhr' | 'fetch' | 'prefetch' | 'eventsource' | 'websocket' | 'manifest' | 'signedexchange' | 'ping' | 'cspviolationreport' | 'preflight' | 'other'; +/** Options fields shared by all quick actions. */ +interface BrowserRunBaseOptions { + /** Adds `