diff --git a/.env.example b/.env.example index 03931349..8e460ca0 100644 --- a/.env.example +++ b/.env.example @@ -52,14 +52,6 @@ TURNSTILE_SECRET_KEY= TURSO_DATABASE_URL= TURSO_AUTH_TOKEN= -# ── Durable public-history scans (required for high-volume accounts) ─────── -# Turso persists every cursor and final snapshot. Run `pnpm public-scan:worker` -# under a supervisor on an always-on server with these same credentials. The -# worker claims durable jobs directly from Turso; no Vercel Cron or callback -# secret is used. Start with two lanes and raise only after observing GitHub API -# rate limits and worker latency. -PUBLIC_SCAN_WORKER_CONCURRENCY=2 - # ── Admin endpoints (optional) ──────────────────────────────────────────── # Secret for the profile backfill endpoint (POST /api/admin/backfill-profiles # with header `x-admin-secret`). No default → the endpoint is inert until set. diff --git a/README.md b/README.md index 3842eb11..16584d51 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,10 @@ TURSO_DATABASE_URL=file:./local.db 1. Push to GitHub, import in Vercel. 2. Configure environment variables (as above). `UPSTASH_*` can be provisioned in one click via Vercel's Upstash integration. 3. Grab a Cloudflare Turnstile site/secret key pair; set `NEXT_PUBLIC_TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`. -4. (Optional) Turso: `TURSO_DATABASE_URL` + `TURSO_AUTH_TOKEN` to enable the leaderboard, archived reports, profile comments/reactions, and resumable public-history scans. -5. Run `pnpm public-scan:worker` as a supervised service on a server with the same `TURSO_*` and GitHub API environment variables. It continuously claims the Turso-backed public-scan queue; Vercel Cron is not used. The default is two globally leased lanes, with `PUBLIC_SCAN_WORKER_CONCURRENCY=1..4` for controlled tuning. -6. (Optional) GitHub OAuth: `AUTH_GITHUB_ID` + `AUTH_GITHUB_SECRET` + `AUTH_SECRET` to enable signed-in comments/reactions. -7. Set both `NEXT_PUBLIC_SITE_URL` and `PUBLIC_SITE_URL` to the same HTTPS origin. Vercel Production rejects a missing, local, HTTP, malformed, or mismatched value during the build so metadata, sitemap, API profile URLs, cards, and LLM attribution cannot drift. -8. Deploy the web application and the worker service independently. +4. (Optional) Turso: `TURSO_DATABASE_URL` + `TURSO_AUTH_TOKEN` to enable the leaderboard, archived reports, profile comments/reactions, and persisted quick-scan profiles. +5. (Optional) GitHub OAuth: `AUTH_GITHUB_ID` + `AUTH_GITHUB_SECRET` + `AUTH_SECRET` to enable signed-in comments/reactions. +6. Set both `NEXT_PUBLIC_SITE_URL` and `PUBLIC_SITE_URL` to the same HTTPS origin. Vercel Production rejects a missing, local, HTTP, malformed, or mismatched value during the build so metadata, sitemap, API profile URLs, cards, and LLM attribution cannot drift. +7. Deploy the web application. ## Bring your own model / API key diff --git a/docs/operations/public-scan-monitoring.md b/docs/operations/public-scan-monitoring.md deleted file mode 100644 index 1f432c0d..00000000 --- a/docs/operations/public-scan-monitoring.md +++ /dev/null @@ -1,87 +0,0 @@ -# Public scan worker operations - -This runbook covers the canonical durable public-scan queue. It does not permit -global recollection, version aliases, direct score writes, or unbounded -request-side queue draining. - -## Processing model - -```mermaid -flowchart LR - A["Explicit request creates one job"] --> B["Request head start: one bounded step"] - B --> C["Turso queue and global leases"] - D["Supervised server worker"] --> C - C --> E["Canonical worker phase"] - E --> F["Aggregate metrics and structured logs"] - F --> G["Authenticated operations endpoint"] -``` - -The server worker is the primary consumer. A request head start is optional and -can only address the job atomically created by that request. A busy execution -slot returns its claim to the ready queue without changing attempt count or -schedule. Storage and metrics failures are distinct from an empty queue or a -busy slot. - -## Start the worker - -Run the worker under a process supervisor on a server that has the existing -Turso and GitHub API environment variables: - -```ini -[Service] -WorkingDirectory=/srv/ghfind -ExecStart=/usr/bin/env pnpm public-scan:worker -Restart=always -RestartSec=5 -Environment=PUBLIC_SCAN_WORKER_CONCURRENCY=2 -``` - -The default concurrency is two lanes and the enforced maximum is four. The -capacity is persisted in Turso, so web-request head starts share the same -global limit. Do not increase it to silence alerts; first inspect GitHub quota, -per-phase duration, and error rate. - -## Aggregate endpoint - -Use an operator credential only in a private shell. Do not paste its value into -issues, pull requests, logs, or screenshots. - -```bash -curl -fsS -H "x-admin-secret: $ADMIN_SECRET" \ - "$BASE_URL/api/admin/public-scan-jobs" -``` - -The response contains no account names, IP addresses, payloads, lease tokens, -or error text. Its `metrics` object includes: - -| Field | Meaning | -| --- | --- | -| `queue.depth` | Canonical queued plus running jobs | -| `queue.ready` / `queue.deferred` | Ready now versus scheduled for later | -| `queue.oldestAgeMs` | Age of the oldest active canonical job | -| `queue.byPhase` | Queued/running counts for each bounded phase | -| `failures.*` | Current failed jobs and cumulative retry/terminal steps | -| `execution.*` | Active slots, configured capacity, and cumulative contention | -| `steps[]` | Per-phase/outcome count, average duration, and maximum duration | -| `worker.lastSuccessAt` | Last successfully completed worker iteration | -| `worker.consecutiveFailures` | Consecutive worker iteration failures | - -## Initial alerts - -| Signal | Warning | Critical | -| --- | --- | --- | -| Time since `worker.lastSuccessAt` | 2 minutes | 5 minutes | -| `queue.oldestAgeMs` | 10 minutes | 20 minutes | -| `queue.depth` | 18 | 24 | -| New `failed_terminal` steps | any | 3 within 15 minutes | -| `worker.consecutiveFailures` | 1 | 2 | - -## Safety checks - -- `GET /api/scan-status/:username` must not change queue state or invoke GitHub. -- Repeating a request for an existing pending job must not create another job. -- Slot contention must leave `attempt_count` and `next_run_at` unchanged. -- The worker must only use the existing Turso queue APIs; never import scraped - data directly into `scores`, snapshots, or ranking tables. -- Disabling the worker stops consumption but does not delete jobs. Use the - existing aggregate inventory and bounded quarantine switch for release work. diff --git a/docs/plans/2026-07-17-durable-public-scan-design.md b/docs/plans/2026-07-17-durable-public-scan-design.md deleted file mode 100644 index 555f57f1..00000000 --- a/docs/plans/2026-07-17-durable-public-scan-design.md +++ /dev/null @@ -1,256 +0,0 @@ -# Durable Public Scan Design - -## Status and Scope - -This document describes the durable public-history scan as implemented on -`feat/durable-public-scan`, plus the explicitly deferred follow-ups. It is a -factual collection pipeline only: the deterministic score and the writer keep -their existing responsibilities. - -### Constraints - -- Do not add a queue SaaS, callback broker, or new external billing boundary. -- Use the existing GitHub API, Turso database, and Vercel deployment only. -- Never turn incomplete public-history coverage into a final score, leaderboard - row, or writer claim. -- Do not make a normal 24-hour cache expiry trigger a historical GitHub crawl. -- Do not claim private activity or commits GitHub cannot attribute to the login. - -## Why This Exists - -The ordinary synchronous scan is intentionally bounded. It is sufficient for -most accounts, but it cannot represent an account with a long public PR history -or an account whose `commitContributionsByRepository` GraphQL resolver exceeds -GitHub's resource budget. - -The failure mode to avoid is not merely a request error. A bounded recent sample -can omit old high-impact work, and an unavailable contribution graph can omit -commit-only work. Publishing a score or roast from either partial input would -make a factual claim the collector has not earned. - -## Request and Cache Policy - -### Ordinary account - -1. Read the Redis scan cache. Its normal TTL remains 24 hours. -2. On a Redis miss, read the current `complete_public` Turso snapshot first. -3. If that snapshot exists, recompute the deterministic score from its stored - facts, repopulate Redis, and return it. No GitHub crawl and no LLM call run. -4. Only accounts without a complete current snapshot run the bounded quick - collector. - -When a durable run is already `queued` or `running`, every entry point returns -its pending status directly. It does not clear the quick Redis entry or repeat -the bounded GitHub collector while waiting for the historical job. - -Therefore, Redis expiry is an accelerator expiry, not permission to repeat a -full historical scan. Score-formula changes also recompute from the durable -facts instead of crawling GitHub again. - -### Snapshot integrity, legacy data, and refresh boundaries - -The current collection contract is `PUBLIC_SCAN_COLLECTION_VERSION = v3`. -A stored run is accepted as `complete_public` only when all of the following -are true: - -- the row has `state=complete_public` and `coverage=complete_public`; -- all required sources (`quick`, owned repositories, native merged PRs, - workflow-landed PRs, and commit recovery) are marked `complete`; -- the SHA-256 of the stored snapshot matches `snapshot_hash`; and -- the snapshot has every required numeric scoring fact plus the required arrays. - -Missing hash, invalid JSON, incomplete source state, or a hash mismatch is -treated as incomplete data, never as a valid old score. The next eligible -request creates a new current-version job; it does not publish the suspect -snapshot. The `v3` collection-version bump similarly makes pre-v3 historical -snapshots eligible for one on-demand recollection. v3 persists public -`fork/private` metadata for commit-only recovery so those repositories cannot be -mistaken for eligible external impact. A future collector-semantic change must -bump the collection version again. - -There are two distinct meanings of incremental here: - -1. **Implemented:** a running collection is incremental and resumable. Every - page cursor and fact write is durable, so the next `after()`/Cron step picks - up where the previous one stopped instead of restarting the history crawl. -2. **Not yet implemented:** a completed snapshot does not yet have a - watermark-based delta refresh that fetches only newly changed history. It - also does not refresh merely because Redis expires. Collection-version - changes or an explicit future refresh policy perform a new complete pass, - while ordinary reads reuse the existing complete snapshot. - -### Admission to durable collection - -The quick collector is used only to establish basic facts and decide whether -full public-history coverage is required. It admits a durable job when any of -the following is true: - -- GitHub rejected per-repository commit aggregation; -- the native merged PR aggregate is intentionally incomplete; -- native merged PR count is greater than 300; -- total PR count is greater than 600; or -- GitHub reports more public repositories than the quick two-page inventory - fetched. - -When GitHub already reports more than 300 merged PRs, the quick path skips its -known-incomplete 300-PR aggregate. This avoids both an invalid partial impact -signal and the GraphQL resource-limit failure seen on very large accounts. It -sets `merged_pr_contribution_aggregation_incomplete` and enqueues the full -paginator instead. - -While no complete snapshot exists, `/api/scan`, `/api/score/:username`, and -`/api/roast` return a pending response rather than a partial final result. The -browser may poll scan status, but it does not execute collection work. - -Only server-collected quick scans (the current request's GitHub result or the -server Redis cache) may seed the durable job's quick phase. A client-supplied -`/api/roast` body can be used for its immediate compatibility response, but it -never becomes a durable fact: when it needs public-history collection, the job -starts with its own server-side quick phase. - -## Durable Queue and Server Execution - -Turso is both the durable queue and the source of truth. - -| Component | Responsibility | -| --- | --- | -| `public_scan_runs` | Run state, source coverage, quick input, final immutable snapshot, diagnostics. | -| `public_scan_jobs` | One active resumable job per username and collection version; stores phase, cursor payload, retry time, and lease. | -| `public_scan_*_facts` | Durable raw PR, owned-repository, commit-candidate, and verified commit facts. | -| execution lease table | One process-wide worker slot, so concurrent requests and Cron invocations cannot multiply GitHub usage. | -| admission window table | Atomic per-source job budget and global active-job ceiling for new historical jobs. | - -Creating a new durable job is protected in the same Turso write transaction as -job insertion. Existing jobs are reused without consuming a new budget. New -jobs have a maximum of 24 active runs globally and two admissions per hashed -request source per hour. The database stores a one-way hash of the source, not -the raw IP address or Bearer credential. Reads, completed snapshots, ordinary -Redis cache hits, and status polling are never charged against this budget. - -### Dispatch - -1. A public API route creates or reuses the Turso job atomically. -2. The same server request calls Next.js `after()` and drains at most one - worker step or 10 seconds. This gives a new job a server-side head start - without making the initiating high-history request wait behind multiple - GitHub pages; it is not browser work and does not rely on process memory. -3. A supervised server process runs `pnpm public-scan:worker` continuously. - It claims one persisted Turso queue step per lane, resuming from the cursor - written by the prior step without any Vercel scheduling dependency. -4. Every invocation claims a database lease. Request-side head starts retain a - short lease; the dedicated worker uses a longer lease so a normal page of - GitHub evidence cannot be terminated by a serverless runtime limit. - -There is no QStash client, callback URL, signing key, or Vercel Cron -credential. Turso is the durable queue and the supervised worker is its sole -continuous consumer. - -### Deployment prerequisite - -The web deployment and worker service both require: - -```text -TURSO_DATABASE_URL= -TURSO_AUTH_TOKEN= -``` - -The worker service also requires the existing GitHub API credentials. It must -run under a process supervisor and restart on failure. - -## Collection Phases - -Each phase persists its result and next cursor before returning. Normal -continuations are immediately eligible for the next worker iteration. Only -explicit quota/backoff states defer `next_run_at`. Replays are idempotent -through run-scoped uniqueness keys and the execution lease. - -1. **Quick facts**: bounded profile, contribution totals, recent samples, and - current repository overview. A route may seed only an already-paid, - server-authored GitHub result; otherwise the worker collects it once. -2. **Original repositories**: REST-page every public, non-fork owned repository. - These are fed through the existing original-project-quality filters; full - inventory does not make empty, profile-config, WIP, blog, or other low-signal - repositories representative work. -3. **Native merged PRs**: GraphQL-page all public GitHub-native merged PRs in - pages of 100, then aggregate impact from the complete durable fact set rather - than the recent PR sample. -4. **Workflow-landed PRs**: page closed PR candidates separately. Only an exact - official workflow label plus verified closing evidence may be counted as - workflow-landed; they stay distinct from GitHub-native merges. -5. **Commit-only recovery**: if GitHub rejects the contribution graph, discover - public commit candidates with `author:` and date windows, split ranges - above the Search result cap, then verify only default-branch commits before - counting them. Public fork/private metadata is carried from candidate through - verification and aggregation, so the existing scorer excludes ineligible - repositories consistently with PR-derived evidence. -6. **Publication**: only after every required source is complete, materialize a - `complete_public` snapshot, cache it in Redis, and make it available to the - normal scan/score/roast routes. - -The durable worker does not itself call an LLM or write a roast/leaderboard row. -Those existing routes consume the complete factual snapshot afterwards, so -writer style cannot affect collection or deterministic scoring. - -## Quotas, Retries, and Failure Semantics - -- One active job per username and collection version prevents duplicate scans. -- New jobs are admitted atomically with a global active-job ceiling and a - source-hashed hourly budget. If Turso cannot enforce either guard, no new - durable job is created. -- One global execution slot serializes durable work across server instances. -- Commit Search reserves a Turso-backed bucket of 20 requests per minute before - each page; Redis is not required for that safeguard. -- A worker error retries with 5, 10, 20, then 40-second backoff. After the - bounded attempts are exhausted, the run becomes `failed` with diagnostics. -- A failed run is retried by a later request only after a 15-minute cooldown. -- If Turso is unavailable, the API returns an unavailable response; it does not - pretend a queue exists or publish a partial result. -- If Redis is unavailable, Turso remains the queue and source of truth. The - system loses only cache acceleration. -- Vercel does not retry failed Cron requests itself. That is safe because the - job row remains queued/running with its lease and the next scheduled request - can recover it. - -The UI should keep a previous `complete_public` report visible during a later -collection refresh. If no complete snapshot exists, it should show collection -status rather than inventing a score or report. - -## Score and Writer Boundaries - -- The score formula is not changed by this design. -- A complete snapshot recalculates the existing deterministic score at read - time, allowing score-version updates without a new history crawl. -- The writer receives a complete factual snapshot only. It must treat - `recent_prs` as a recent sample and use durable aggregate fields for claims - about all-time PR and impact totals. -- Self-closed PRs remain classified by the existing scoring rules; the durable - collector preserves the raw state and does not add an automatic penalty. - -## Deferred Work - -These are intentionally not part of the current implementation: - -1. Completed-snapshot delta refresh using durable watermarks and overlap - windows. Until that exists, a collection-version change or explicit - collection policy is the only reason to crawl history again; Redis TTL is - never a reason. -2. Operator dashboards for queue depth, phase duration, GitHub quota use, and - failed-run diagnostics. -3. Retention/compaction policy for high-volume raw commit-discovery rows after - their verified per-repository aggregate has been retained. -4. Adaptive per-phase concurrency and GitHub quota tuning beyond the current - bounded server-worker capacity. - -## Acceptance Checks - -- A normal Redis miss with a complete Turso snapshot performs no GitHub crawl. -- A 300+ merged-PR account returns `collecting_public_history` instead of - failing in the bounded aggregate query. -- Restarting the server between phases resumes from the persisted cursor. -- Concurrent API and worker drains never exceed the persisted execution capacity. -- A contribution-graph resource limit reaches verified commit recovery rather - than producing zero commit-only impact as a final result. -- No final score, leaderboard fact, or writer report is generated from a - partial run. -- The worker service accepts no request credential; public scan requests create - jobs only and cannot command the worker. diff --git a/docs/releases/v9-v9-v4-rollout.md b/docs/releases/v9-v9-v4-rollout.md index cca4cbcb..d6a69425 100644 --- a/docs/releases/v9-v9-v4-rollout.md +++ b/docs/releases/v9-v9-v4-rollout.md @@ -1,144 +1,38 @@ -# v9 / v9 / v4 release plan +# v9/v9/v4 Release Contract -## Formal lineage +## Runtime Contract -| Component | Previous release | Target release | Read behavior during rollout | -| --- | --- | --- | --- | -| Deterministic score | v8 | v9 | Prefer v9; keep the last complete v8 score visible | -| Roast report | v8 | v9 | Replay only when both score and roast are v9 | -| Public collection | v3 | v4 | Prefer complete v4; use complete v3 only as an explicit stale fallback | +`v9/v9/v4` is the current canonical release. A user request runs one bounded +quick GitHub scan synchronously, materializes the deterministic v9 score, and +then generates or replays the matching v9 roast. The web application does not +create durable scan jobs, poll a queue, or depend on Vercel Cron or a resident +worker. -Unpublished local version values are not release versions. They are not aliases, -replay inputs, backfill sources, or migration targets. +If the quick collector cannot complete, a verified `v5/v5/v3` artifact may be +served as a read-only emergency fallback. A successful quick scan always takes +precedence and overwrites the current profile atomically. -## Rollout sequence +## Deployment Checks -```mermaid -flowchart LR - A["#121: CI and legacy-read guardrails"] --> B["#126: normalize runtime to v9/v9/v4"] - B --> C["#120: targeted durable jobs and queue metrics"] - C --> D["#124: event admission and overload controls"] - D --> E["#118: persist deterministic v9 score"] - E --> F["#122 and #125: origin check and event UI"] - F --> G["#119: optional v3 stale fallback"] - G --> H["Owner: protected staging smoke and promotion"] -``` +1. Run `pnpm versions:check`, `pnpm typecheck`, `pnpm lint`, and `pnpm test`. +2. Deploy the web application and verify `/api/scan`, `/api/score/{username}`, + and a default-model `/api/roast` complete without a `202` or + `scan_enrichment_pending` response. +3. Verify a known v5/v5/v3 profile is served only when an induced quick-scan + failure occurs. +4. Run `pnpm smoke:deployment` with the normal profile, score, autocomplete, + leaderboard, and facet canaries. -The first guardrail phase uses `source_changes_only`: CI permits the untouched -pre-normalization source, but any pull request that edits a version constant must -set all edited runtime values directly to the formal target. #126 changes the -manifest to `canonical`, after which every CI run requires exact runtime equality. -Pull requests compare against their target branch; direct pushes compare against -the event's previous commit. After normalization, no approval field can permit a -multi-component bump, and release history cannot be rewritten in place. +## Change Control -## Capacity and write policy - -- A deploy must not enqueue work merely because a stored version is old. -- Profile, score, search, leaderboard, facet, sitemap, project, similar, and - following reads continue serving the last complete stored score. -- Only an explicit scan or roast flow may request refresh work. -- New collection work is bounded and version-aware before v4 is enabled. -- Worker recovery and claim are restricted to v4 before any GitHub call. -- Non-v4 active jobs do not consume v4 admission capacity. Their quarantine is - aggregate-only, dry-run by default, environment-gated, and hard-capped per call. -- Quarantine defers a running job while its lease is valid and never releases an - execution slot early; expired work is fenced before it is marked obsolete. -- A profile page read never starts a snapshot backfill merely because evidence - is absent; explicit write endpoints remain the only refresh entry points. -- Default-model score/report writes require a server-produced quick scan or a - complete durable snapshot. A client-provided scan is BYO-only, immediate, and - cannot write scores, cache reports, or create durable collection work. -- Quick-scan cache identity depends only on the collection contract and scores - are recomputed on read. Report and single-flight identities depend on the - collection, score, and roast contracts together. -- No global rescore, recollection, or roast regeneration runs during promotion. - -Queue metrics, privacy-safe structured logs, alert thresholds, and owner-only -Vercel steps are documented in -[`docs/operations/public-scan-monitoring.md`](../operations/public-scan-monitoring.md). - -## #126 normalization boundary - -This normalization does not persist a score when a durable scan completes. #118 -owns the idempotent v4-snapshot to v9-score write and the atomic replacement of a -stale score. Until then, a complete v4 snapshot is factual input only. - -When a score version or deterministic result changes, previously generated roast -text is detached. A roast write is accepted only while the account row still has -the exact score-write token and timestamp, preventing both cross-release and -same-release late reports from attaching to a newer scan. - -## v3 stale-read fallback - -When no valid complete v4 snapshot exists, public scan status may serve the -newest complete v3 snapshot only after its hash, account identity, coverage, -required sources, payload shape, and stored-score shape all validate. The -response marks `stale`, `served_collection_version=v3`, and -`target_collection_version=v4`. It never materializes a v9 score or report -from v3 evidence. - -Passive profile, score, search, leaderboard, facet, sitemap, and MCP reads do -not create a refresh. An explicit scan may create at most one v4 job and keeps -returning the v3 snapshot while that refresh is pending or failed. v5 and every -other non-formal collection remain ineligible for this path. - -## v5 emergency artifact fallback - -The canonical runtime remains v9/v9/v4. Separately, a read-only continuity -path may expose an already-persisted v5 score and v5 roast only when the same -account has a complete, hash-validated v3 public snapshot recorded with score -version v5. This exact v5/v5/v3 tuple is not an alias, a formal compatibility -target, a queue target, or a write/migration source. - -The fallback serves existing profile and score reads as stale and can replay the -stored report without an LLM configuration, Cron run, cache write, scan, or score -materialization. A home-page explicit scan first returns the verified v5/v5/v3 -profile and report handoff, then best-effort starts or resumes only its canonical -v9/v4 refresh; queue admission or storage failure cannot turn that readable -result into a waiting screen. It never reconstructs a report from -`score_snapshots`: those rows do not contain report markdown. Missing, -mismatched, corrupt, or incomplete tuple members fail closed. - -When that explicit handoff has a durable refresh run, the profile polls only the -opaque run id already issued by the server. On complete v4 publication it makes -one user-initiated v9 report request, then refreshes to the current v9/v9/v4 -profile. Cron never generates reports, and passive/shared profile visits do not -poll, enqueue, or spend model credit. - -## Obsolete-job quarantine - -The operator endpoint returns aggregate counts only: - -```bash -curl -fsS -H "x-admin-secret: $ADMIN_SECRET" \ - "$BASE_URL/api/admin/public-scan-jobs" -curl -fsS -X POST -H "x-admin-secret: $ADMIN_SECRET" \ - -H "content-type: application/json" \ - --data '{"limit":25}' \ - "$BASE_URL/api/admin/public-scan-jobs" -``` - -Both calls above are read-only. To apply one bounded batch, set -`PUBLIC_SCAN_QUARANTINE_ENABLED=1` on the intended deployment and send -`{"apply":true,"limit":25}`. Keep invoking dry-run between batches; a positive -`deferredActive` means a running lease is still valid and must be allowed to -expire. Disable the environment switch after the aggregate count reaches zero. +Only one release component may change in a pull request. A release change must +include its migration/read behavior, rollback steps, and production smoke +coverage. Never use local-only version constants, and never invalidate stored +profiles by requiring a newly bumped version before its replacement data exists. ## Rollback -1. Stop new scan admission with the queue kill switch. -2. Revert the isolated version-enablement pull request and its manifest state. -3. Keep public reads on the last complete v8 score and complete v3 collection. -4. Do not rewrite, alias, or replay unpublished local-version rows. -5. Promote only after profile, score API, search, leaderboard, facet, scan status, - and canonical-origin smoke checks pass against staging. - -The scoring formula and the removal of model-authored score deltas are outside -this release-control change and must remain covered by the existing test suite. - -## Owner controls - -The owner must use isolated staging Turso, Redis, GitHub quota, and Cron secrets. -After this workflow lands, configure `Verify release` as a required check on -`dev` and `main`, and make the private deployment smoke a promotion gate. +If the v9 quick path is unhealthy, revert the responsible application commit +while retaining existing `v5/v5/v3` artifacts. Do not alter score, roast, or +collection version constants as an incident shortcut; restore read behavior +first, then repair the producer in a separately reviewed change. diff --git a/package.json b/package.json index d722e0d0..5f5dc5fe 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "next dev", "build": "next build", - "public-scan:worker": "tsx scripts/public-scan-worker.mts", "cli:build": "make cli-build", "cli:build-all": "make cli-build-all", "cli:test": "make cli-test", diff --git a/scripts/public-scan-worker.mts b/scripts/public-scan-worker.mts deleted file mode 100644 index 65251257..00000000 --- a/scripts/public-scan-worker.mts +++ /dev/null @@ -1,75 +0,0 @@ -import "./_env.mjs"; -import { configurePublicScanExecutionCapacity } from "../src/lib/db"; -import { drainPublicScanJobsFromWorker } from "../src/lib/public-scan-dispatcher"; - -const DEFAULT_CONCURRENCY = 2; -const MAX_CONCURRENCY = 4; -const IDLE_DELAY_MS = 1_000; -const SLOT_BUSY_DELAY_MS = 250; -const FAILURE_DELAY_MS = 5_000; -const WORKER_LEASE_MS = 5 * 60 * 1_000; -const IDLE_HEARTBEAT_INTERVAL_MS = 30_000; - -function configuredConcurrency(): number { - const raw = process.env.PUBLIC_SCAN_WORKER_CONCURRENCY; - if (raw === undefined || raw === "") return DEFAULT_CONCURRENCY; - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_CONCURRENCY) { - throw new Error(`PUBLIC_SCAN_WORKER_CONCURRENCY must be an integer from 1 to ${MAX_CONCURRENCY}`); - } - return parsed; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function errorKind(error: unknown): string { - return error instanceof Error ? error.constructor.name : "Unknown"; -} - -let stopping = false; -let nextIdleHeartbeatAt = 0; -function stop(): void { - stopping = true; -} - -function shouldRecordIdleHeartbeat(): boolean { - const now = Date.now(); - if (now < nextIdleHeartbeatAt) return false; - nextIdleHeartbeatAt = now + IDLE_HEARTBEAT_INTERVAL_MS; - return true; -} - -process.once("SIGINT", stop); -process.once("SIGTERM", stop); - -async function runLane(lane: number): Promise { - while (!stopping) { - try { - const result = await drainPublicScanJobsFromWorker({ - leaseMs: WORKER_LEASE_MS, - recordIdleHeartbeat: shouldRecordIdleHeartbeat(), - }); - if (result.processed > 0) continue; - const slotBusy = result.results.some((step) => step.status === "slot_busy"); - await sleep(slotBusy ? SLOT_BUSY_DELAY_MS : IDLE_DELAY_MS); - } catch (error) { - console.error( - "public_scan.service_lane_failed", - JSON.stringify({ lane, errorType: errorKind(error) }), - ); - await sleep(FAILURE_DELAY_MS); - } - } -} - -const concurrency = configuredConcurrency(); -const capacity = await configurePublicScanExecutionCapacity({ capacity: concurrency }); -console.info( - "public_scan.service_started", - JSON.stringify({ capacity: capacity.capacity, changed: capacity.changed, lanes: concurrency }), -); - -await Promise.all(Array.from({ length: concurrency }, (_, index) => runLane(index + 1))); -console.info("public_scan.service_stopped", JSON.stringify({ lanes: concurrency })); diff --git a/scripts/smoke-deployment.mts b/scripts/smoke-deployment.mts index 5ae82829..82c7104f 100644 --- a/scripts/smoke-deployment.mts +++ b/scripts/smoke-deployment.mts @@ -17,13 +17,6 @@ function required(name: string): string { return value; } -function optionalPair(left: string, right: string): [string, string] | null { - const a = process.env[left]?.trim(); - const b = process.env[right]?.trim(); - if (Boolean(a) !== Boolean(b)) throw new Error(`${left} and ${right} must be configured together`); - return a && b ? [a, b] : null; -} - function record(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("response must be a JSON object"); @@ -86,16 +79,6 @@ async function main(): Promise { throw new Error("SMOKE_FACET_TYPE must be language, org, or repo"); } const facetValue = required("SMOKE_FACET_VALUE"); - const completeHandle = handle( - required("SMOKE_COMPLETE_HANDLE"), - "SMOKE_COMPLETE_HANDLE", - ); - const completeRun = required("SMOKE_COMPLETE_RUN_ID"); - const pending = optionalPair("SMOKE_PENDING_HANDLE", "SMOKE_PENDING_RUN_ID"); - if (!pending && process.env.SMOKE_REQUIRE_PENDING === "1") { - throw new Error("pending scan canary is required for this promotion"); - } - const expectedOrigin = base.origin; const checks: Check[] = [ { @@ -142,32 +125,8 @@ async function main(): Promise { if (!Array.isArray(record(body).entries)) throw new Error("facet entries are missing"); }, }, - { - label: "complete scan status", - path: `/api/scan-status/${encodeURIComponent(completeHandle)}?run_id=${encodeURIComponent(completeRun)}`, - status: 200, - validate(body) { - if (record(body).status !== "complete_public") { - throw new Error("complete scan canary is not complete_public"); - } - }, - }, ]; - if (pending) { - const [pendingHandle, pendingRun] = pending; - checks.push({ - label: "pending scan status", - path: `/api/scan-status/${encodeURIComponent(handle(pendingHandle, "SMOKE_PENDING_HANDLE"))}?run_id=${encodeURIComponent(pendingRun)}`, - status: 202, - validate(body) { - if (record(body).status !== "pending") { - throw new Error("pending scan canary is not pending"); - } - }, - }); - } - for (const check of checks) await runCheck(base, check); console.log(`PASS deployment smoke (${checks.length} checks)`); } diff --git a/src/app/[locale]/u/[username]/page.tsx b/src/app/[locale]/u/[username]/page.tsx index 53231691..1faf4801 100644 --- a/src/app/[locale]/u/[username]/page.tsx +++ b/src/app/[locale]/u/[username]/page.tsx @@ -44,7 +44,6 @@ import { BadgeReferralBanner } from "@/components/BadgeReferralBanner"; import { RepoCardLink } from "@/components/RepoCardLink"; import { ProfileLandingBeacon } from "@/components/ProfileLandingBeacon"; import { ChallengeCta } from "@/components/ChallengeCta"; -import { CanonicalProfileUpgrade } from "@/components/CanonicalProfileUpgrade"; import { FollowButton } from "@/components/FollowButton"; import { FacetRankLink } from "@/components/FacetRankLink"; import { CommonProjects } from "@/components/CommonProjects"; @@ -234,7 +233,6 @@ export default async function AccountPage({ // (the homepage's /api/scan call just re-scanned, so the score is current). // Direct visits / shared links (no param) keep the popup-free SSR page. const fromHome = query.roasting === "1"; - const refreshRunId = typeof query.refresh_run_id === "string" ? query.refresh_run_id : null; const legacyReadFallback = d.legacy_read_fallback; // eslint-disable-next-line react-hooks/purity -- force-dynamic Server Component: rendered per request, so wall-clock staleness here is intentional (and the server re-validates before spending LLM credit) const staleReroast = !legacyReadFallback && fromHome && Boolean(roast) && Date.now() - d.scanned_at > ROAST_FRESH_MS; @@ -446,9 +444,6 @@ export default async function AccountPage({ profileUsername={d.username} initialComments={comments} /> - {legacyReadFallback && refreshRunId && ( - - )}
({ - getPublicScanJobVersionSummary: vi.fn(), - getPublicScanOperationalMetrics: vi.fn(), - quarantineObsoletePublicScanJobs: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - getPublicScanJobVersionSummary: mocks.getPublicScanJobVersionSummary, - getPublicScanOperationalMetrics: mocks.getPublicScanOperationalMetrics, - quarantineObsoletePublicScanJobs: mocks.quarantineObsoletePublicScanJobs, -})); - -import { GET, POST } from "./route"; - -const originalAdminSecret = process.env.ADMIN_SECRET; -const originalQuarantineSwitch = process.env.PUBLIC_SCAN_QUARANTINE_ENABLED; -const aggregateMetrics = { - generatedAt: 1_800_000_000_000, - canonicalCollectionVersion: "v4", - queue: { - depth: 2, - queued: 1, - running: 1, - ready: 1, - deferred: 0, - retrying: 0, - oldestAgeMs: 30_000, - byPhase: [{ phase: "merged_prs", queued: 1, running: 1 }], - }, - failures: { currentFailedJobs: 0, retryingSteps: 1, terminalSteps: 0 }, - execution: { activeSlots: 1, capacity: 1, contentionSteps: 2 }, - obsoleteActiveJobs: 1, - steps: [], - worker: { - lastStartedAt: 1_799_999_999_000, - lastSuccessAt: 1_800_000_000_000, - lastDurationMs: 250, - lastProcessed: 2, - lastFailedSteps: 0, - consecutiveFailures: 0, - }, -}; - -function request(method: "GET" | "POST", body?: unknown, authorized = true) { - return new NextRequest("https://example.test/api/admin/public-scan-jobs", { - method, - headers: authorized - ? { "x-admin-secret": "synthetic-admin-secret", "content-type": "application/json" } - : { "content-type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); -} - -describe("public scan job release operations", () => { - beforeEach(() => { - vi.clearAllMocks(); - process.env.ADMIN_SECRET = "synthetic-admin-secret"; - delete process.env.PUBLIC_SCAN_QUARANTINE_ENABLED; - mocks.getPublicScanJobVersionSummary.mockResolvedValue([]); - mocks.getPublicScanOperationalMetrics.mockResolvedValue(aggregateMetrics); - mocks.quarantineObsoletePublicScanJobs.mockResolvedValue({ - dryRun: true, - selected: 2, - quarantined: 0, - remainingActive: 2, - deferredActive: 0, - }); - }); - - afterEach(() => { - if (originalAdminSecret === undefined) delete process.env.ADMIN_SECRET; - else process.env.ADMIN_SECRET = originalAdminSecret; - if (originalQuarantineSwitch === undefined) { - delete process.env.PUBLIC_SCAN_QUARANTINE_ENABLED; - } else { - process.env.PUBLIC_SCAN_QUARANTINE_ENABLED = originalQuarantineSwitch; - } - }); - - it("requires the operator credential for inventory and quarantine", async () => { - expect((await GET(request("GET", undefined, false))).status).toBe(403); - expect((await POST(request("POST", {}, false))).status).toBe(403); - expect(mocks.getPublicScanJobVersionSummary).not.toHaveBeenCalled(); - expect(mocks.quarantineObsoletePublicScanJobs).not.toHaveBeenCalled(); - }); - - it("returns aggregate inventory without mutation", async () => { - const response = await GET(request("GET")); - expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toBe("no-store"); - await expect(response.json()).resolves.toEqual({ - canonicalCollectionVersion: "v4", - versions: [], - metrics: aggregateMetrics, - }); - expect(mocks.getPublicScanOperationalMetrics).toHaveBeenCalledWith("v4"); - const serialized = JSON.stringify(await (await GET(request("GET"))).json()); - expect(serialized).not.toMatch(/username|payload|leaseToken|secret|error/i); - }); - - it("defaults to a bounded dry-run", async () => { - const response = await POST(request("POST", { limit: 1000 })); - expect(response.status).toBe(200); - expect(mocks.quarantineObsoletePublicScanJobs).toHaveBeenCalledWith({ - canonicalCollectionVersion: "v4", - apply: false, - limit: 100, - }); - }); - - it("requires the deployment switch before applying a batch", async () => { - const disabled = await POST(request("POST", { apply: true, limit: 10 })); - expect(disabled.status).toBe(409); - expect(mocks.quarantineObsoletePublicScanJobs).not.toHaveBeenCalled(); - - process.env.PUBLIC_SCAN_QUARANTINE_ENABLED = "1"; - mocks.quarantineObsoletePublicScanJobs.mockResolvedValue({ - dryRun: false, - selected: 2, - quarantined: 2, - remainingActive: 0, - deferredActive: 0, - }); - const enabled = await POST(request("POST", { apply: true, limit: 10 })); - expect(enabled.status).toBe(200); - expect(mocks.quarantineObsoletePublicScanJobs).toHaveBeenCalledWith({ - canonicalCollectionVersion: "v4", - apply: true, - limit: 10, - }); - }); - - it("treats a null JSON body as a dry-run", async () => { - const response = await POST(request("POST", null)); - expect(response.status).toBe(200); - expect(mocks.quarantineObsoletePublicScanJobs).toHaveBeenCalledWith( - expect.objectContaining({ apply: false, limit: 25 }), - ); - }); -}); diff --git a/src/app/api/admin/public-scan-jobs/route.ts b/src/app/api/admin/public-scan-jobs/route.ts deleted file mode 100644 index 7312836a..00000000 --- a/src/app/api/admin/public-scan-jobs/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; -import { NextRequest, NextResponse } from "next/server"; -import { - getPublicScanJobVersionSummary, - getPublicScanOperationalMetrics, - quarantineObsoletePublicScanJobs, -} from "@/lib/db"; -import { PUBLIC_SCAN_COLLECTION_VERSION } from "@/lib/scan-run-types"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -const DEFAULT_BATCH_LIMIT = 25; -const MAX_BATCH_LIMIT = 100; - -function secureEqual(left: string, right: string): boolean { - const a = Buffer.from(left); - const b = Buffer.from(right); - return a.length === b.length && timingSafeEqual(a, b); -} - -function authorized(req: NextRequest): boolean { - const secret = process.env.ADMIN_SECRET; - const supplied = req.headers.get("x-admin-secret"); - return Boolean(secret && supplied && secureEqual(supplied, secret)); -} - -function json(body: unknown, status = 200) { - return NextResponse.json(body, { - status, - headers: { "Cache-Control": "no-store" }, - }); -} - -function boundedLimit(value: unknown): number { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed)) return DEFAULT_BATCH_LIMIT; - return Math.max(1, Math.min(MAX_BATCH_LIMIT, Math.floor(parsed))); -} - -/** Aggregate inventory only; no account or job identifiers leave this endpoint. */ -export async function GET(req: NextRequest) { - if (!authorized(req)) return json({ error: "forbidden" }, 403); - const [versions, metrics] = await Promise.all([ - getPublicScanJobVersionSummary(), - getPublicScanOperationalMetrics(PUBLIC_SCAN_COLLECTION_VERSION), - ]); - if (!versions || !metrics) return json({ error: "storage_unavailable" }, 503); - return json({ - canonicalCollectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - versions, - metrics, - }); -} - -/** - * Dry-run by default. Applying a bounded batch additionally requires the - * deployment-level switch, preventing an accidental request from mutating jobs. - */ -export async function POST(req: NextRequest) { - if (!authorized(req)) return json({ error: "forbidden" }, 403); - const parsed = await req.json().catch(() => null); - const body: Record = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - const apply = body.apply === true; - if (apply && process.env.PUBLIC_SCAN_QUARANTINE_ENABLED !== "1") { - return json({ error: "quarantine_disabled" }, 409); - } - - const result = await quarantineObsoletePublicScanJobs({ - canonicalCollectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - apply, - limit: boundedLimit(body.limit), - }); - if (!result) return json({ error: "storage_unavailable" }, 503); - return json({ - canonicalCollectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - ...result, - }); -} diff --git a/src/app/api/roast/route.test.ts b/src/app/api/roast/route.test.ts index b94170e3..9bdaa2ec 100644 --- a/src/app/api/roast/route.test.ts +++ b/src/app/api/roast/route.test.ts @@ -3,1282 +3,117 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ScanResult } from "@/lib/types"; const mocks = vi.hoisted(() => ({ - getArchivedRoast: vi.fn(), - getCanonicalScoreWriteIdentity: vi.fn(), - getLegacyReadFallbackRoast: vi.fn(), - getScoreScannedAt: vi.fn(), - getRank: vi.fn(), - updateRoast: vi.fn(), - chatStreamEvents: vi.fn(), - checkBotId: vi.fn(async () => ({ isBot: false, isVerifiedBot: false })), + acquireRoastLock: vi.fn(), + buildRoastMessages: vi.fn(), + checkBotId: vi.fn(), + checkRoastRateLimit: vi.fn(), checkRoastRequestRateLimit: vi.fn(), + chat: vi.fn(), defaultLlmConfig: vi.fn(), fallbackLlmConfig: vi.fn(), - acquireRoastLock: vi.fn(), - checkRoastRateLimit: vi.fn(), - clearCachedRoast: vi.fn(), + getArchivedRoast: vi.fn(), getCachedRoast: vi.fn(), getCachedScan: vi.fn(), + getCanonicalScoreWriteIdentity: vi.fn(), + getCurrentCanonicalQuickScan: vi.fn(), + getLegacyReadFallbackRoast: vi.fn(), + getRankCached: vi.fn(), + getScoreScannedAt: vi.fn(), rateLimitHeaders: vi.fn(), releaseRoastLock: vi.fn(), setCachedRoast: vi.fn(), + updateRoast: vi.fn(), waitForCachedRoast: vi.fn(), - buildRoastMessages: vi.fn((_scan: ScanResult, _lang?: string) => []), - getPublicScanStatus: vi.fn(), - publicScanAdmission: vi.fn(() => ({ bucket: "test", limit: 2, windowMs: 60_000, maxActiveJobs: 24 })), - requiresDurablePublicScan: vi.fn(), - resolvePublicScanFromTrustedQuickScan: vi.fn(), - startPublicScan: vi.fn(), - kickPublicScanDrain: vi.fn(), -})); - -// Outside a real browser/Vercel request there is no BotID signal — treat every -// test request as a verified human so the gate is transparent to these suites. -vi.mock("botid/server", () => ({ - checkBotId: mocks.checkBotId, })); +vi.mock("botid/server", () => ({ checkBotId: mocks.checkBotId })); vi.mock("@/lib/db", () => ({ getArchivedRoast: mocks.getArchivedRoast, getCanonicalScoreWriteIdentity: mocks.getCanonicalScoreWriteIdentity, + getCurrentCanonicalQuickScan: mocks.getCurrentCanonicalQuickScan, getLegacyReadFallbackRoast: mocks.getLegacyReadFallbackRoast, getScoreScannedAt: mocks.getScoreScannedAt, updateRoast: mocks.updateRoast, })); - -vi.mock("@/lib/rank", () => ({ - getRankCached: mocks.getRank, -})); - -vi.mock("@/lib/badge", () => ({ - TIER_EN: { - 夯: "GOD", - 顶级: "TOP", - 人上人: "ELITE", - NPC: "NPC", - 拉完了: "LOW", - }, - TIER_LABEL_EN: { - 夯: "Legendary", - 顶级: "Top developer", - 人上人: "Trusted contributor", - NPC: "Average account", - 拉完了: "Low value", - }, -})); - -vi.mock("@/lib/lang", () => ({ - normLang: (lang?: string) => (lang === "en" ? "en" : "zh"), -})); - -vi.mock("@/lib/llm", () => { - class LlmTimeoutError extends Error {} - class LlmQuotaError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - } - } - return { - LlmTimeoutError, - LlmQuotaError, - // The route calls the fallback wrapper; delegate to the per-call stream mock - // using the primary config. - chatStreamEventsWithFallback: async function* ( - configs: unknown[], - messages: unknown, - opts: { - onAttempt?: (event: { - attempt: number; - provider: string; - model: string; - phase: string; - elapsedMs: number; - emittedContent?: boolean; - }) => void; - }, - ) { - const base = { attempt: 1, provider: "llm.example.test", model: "test-model" }; - opts.onAttempt?.({ ...base, phase: "start", elapsedMs: 0 }); - let first = true; - for await (const event of mocks.chatStreamEvents(configs[0], messages, opts)) { - if (first) { - first = false; - opts.onAttempt?.({ ...base, phase: "first_event", elapsedMs: 1 }); - if (event.type === "content") { - opts.onAttempt?.({ ...base, phase: "first_content", elapsedMs: 1 }); - } - } - yield event; - } - opts.onAttempt?.({ ...base, phase: "success", elapsedMs: 2, emittedContent: true }); - }, - chatStreamEvents: mocks.chatStreamEvents, - defaultLlmConfig: mocks.defaultLlmConfig, - fallbackLlmConfig: mocks.fallbackLlmConfig, - }; -}); - +vi.mock("@/lib/rank", () => ({ getRankCached: mocks.getRankCached })); vi.mock("@/lib/redis", () => ({ acquireRoastLock: mocks.acquireRoastLock, - checkRoastRequestRateLimit: mocks.checkRoastRequestRateLimit, checkRoastRateLimit: mocks.checkRoastRateLimit, - clearCachedRoast: mocks.clearCachedRoast, + checkRoastRequestRateLimit: mocks.checkRoastRequestRateLimit, + clearCachedRoast: vi.fn(), getCachedRoast: mocks.getCachedRoast, getCachedScan: mocks.getCachedScan, - releaseRoastLock: mocks.releaseRoastLock, rateLimitHeaders: mocks.rateLimitHeaders, + releaseRoastLock: mocks.releaseRoastLock, setCachedRoast: mocks.setCachedRoast, waitForCachedRoast: mocks.waitForCachedRoast, })); - -vi.mock("@/lib/percentile", () => ({ - beatPercent: () => 50, -})); - -vi.mock("@/lib/prompt", () => ({ - buildRoastMessages: mocks.buildRoastMessages, -})); - -vi.mock("@/lib/report", () => ({ - reportMatchesLang: () => true, -})); - -vi.mock("@/lib/identity", () => ({ - sanitizeIdentityClaims: ( - _scan: unknown, - tags: unknown, - roastLine: unknown, - report: unknown, - ) => ({ tags, roastLine, report }), -})); - -vi.mock("@/lib/public-scan", () => ({ - getPublicScanStatus: mocks.getPublicScanStatus, - publicScanAdmission: mocks.publicScanAdmission, - requiresDurablePublicScan: mocks.requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan: mocks.resolvePublicScanFromTrustedQuickScan, - startPublicScan: mocks.startPublicScan, -})); - -vi.mock("@/lib/public-scan-dispatcher", () => ({ - kickPublicScanDrain: mocks.kickPublicScanDrain, -})); - -vi.mock("@/lib/score", () => ({ - clampScore: (score: number) => Math.max(0, Math.min(100, score)), - tierFor: (score: number) => - score >= 70 - ? { tier: "人上人", tier_label: "优质贡献者 · 值得信任" } - : { tier: "NPC", tier_label: "普通账号 · 特征平庸存疑" }, +vi.mock("@/lib/prompt", () => ({ buildRoastMessages: mocks.buildRoastMessages })); +vi.mock("@/lib/llm", () => ({ + defaultLlmConfig: mocks.defaultLlmConfig, + fallbackLlmConfig: mocks.fallbackLlmConfig, + chatStreamEventsWithFallback: async function* () { + yield* mocks.chat(); + }, + LlmQuotaError: class LlmQuotaError extends Error {}, + LlmTimeoutError: class LlmTimeoutError extends Error {}, })); import { POST } from "./route"; -async function* streamText(text: string): AsyncGenerator<{ type: "content"; text: string }> { - yield { type: "content", text }; -} - -async function* streamChunks(chunks: string[]): AsyncGenerator<{ type: "content"; text: string }> { - for (const text of chunks) yield { type: "content", text }; -} - -async function* streamThenFail( - text: string, - error: Error, -): AsyncGenerator<{ type: "content"; text: string }> { - yield { type: "content", text }; - throw error; -} - -const scan: ScanResult = { - metrics: { - username: "DemoDev", - profile_url: "https://github.com/DemoDev", - avatar_url: "https://avatars.githubusercontent.com/u/1", - name: "Demo Dev", - bio: "Maintainer", - company: null, - account_age_years: 5, - created_at: "2020-01-01T00:00:00Z", - followers: 120, - following: 20, - public_repos: 12, - fetched_repo_count: 12, - original_repo_count: 8, - nonempty_original_repo_count: 8, - fork_repo_count: 4, - empty_original_repo_count: 0, - total_stars: 500, - max_stars: 260, - merged_pr_count: 30, - total_pr_count: 35, - issues_created: 12, - last_year_contributions: 900, - activity_type_count: 4, - contribution_years_active: 4, - days_since_last_activity: 2, - recent_merged_pr_sample: 10, - recent_trivial_pr_count: 1, - external_trivial_pr_count: 1, - max_impact_repo_stars: 10_000, - impact_pr_count: 8, - impact_depth_raw: 3, - star_inflation_suspect: false, - closed_unmerged_pr_count: 1, - pr_rejection_rate: 0.03, - recent_pr_sample: 12, - top_repo_pr_target: null, - top_repo_pr_share: 0, - templated_pr_ratio: 0, - pr_flood_suspect: false, - }, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - impact_repos: [], - verified_impact_prs: [], - scoring: { - sub_scores: { - account_maturity: 8, - original_project_quality: 12, - contribution_quality: 18, - ecosystem_impact: 12, - community_influence: 5, - activity_authenticity: 13, - }, - base_score: 68, - red_flags: [], - total_penalty: 0, - final_score: 68, - tier: "NPC", - tier_label: "普通账号 · 特征平庸存疑", - }, -}; - -const scoreIdentity = { - scannedAt: 1_800_000_000_000, - token: "synthetic-score-write-token", -}; -const canonicalSnapshotHash = "ab".repeat(32); - -function completePublicStatus(trustedScan: ScanResult = scan) { - return { - status: "complete" as const, - run: { snapshotHash: canonicalSnapshotHash }, - scan: trustedScan, - }; -} - -beforeEach(() => { - vi.clearAllMocks(); - mocks.defaultLlmConfig.mockReturnValue({ - baseURL: "https://llm.example.test/v1", - apiKey: "test-key", - model: "test-model", - }); - mocks.fallbackLlmConfig.mockReturnValue(null); - mocks.getCachedScan.mockResolvedValue(scan); - mocks.getPublicScanStatus.mockResolvedValue(completePublicStatus()); - mocks.requiresDurablePublicScan.mockReturnValue(false); - mocks.getCachedRoast.mockResolvedValue(null); - mocks.getArchivedRoast.mockResolvedValue(null); - mocks.getLegacyReadFallbackRoast.mockResolvedValue(null); - mocks.getScoreScannedAt.mockResolvedValue(null); - mocks.checkRoastRequestRateLimit.mockResolvedValue({ success: true }); - mocks.clearCachedRoast.mockResolvedValue(undefined); - mocks.checkRoastRateLimit.mockResolvedValue({ success: true }); - mocks.rateLimitHeaders.mockReturnValue({}); - mocks.acquireRoastLock.mockResolvedValue(true); - mocks.waitForCachedRoast.mockResolvedValue(null); - mocks.getRank.mockResolvedValue({ rank: 4, below: 5, total: 10 }); - mocks.getCanonicalScoreWriteIdentity.mockResolvedValue(scoreIdentity); - mocks.updateRoast.mockResolvedValue(true); - mocks.setCachedRoast.mockResolvedValue(undefined); - mocks.releaseRoastLock.mockResolvedValue(undefined); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 3@@", - "@@TAGS zh=进步,维护者|en=improving,maintainer@@", - "@@ROAST zh=稳步进步。|en=Steady improvement.@@", - "## 毒舌点评", - "开源活跃度在上升。", - ].join("\n"), - ), - ); -}); - -describe("roast API persistence", () => { - it("replays a verified v5/v5/v3 artifact before any LLM or durable-scan work", async () => { - mocks.getLegacyReadFallbackRoast.mockResolvedValue({ - username: "legacy-read-fixture", - final_score: 73, - tier: "人上人", - tags: { zh: ["旧版"], en: ["legacy"] }, - roast_line: { zh: "旧版锐评。", en: "Legacy roast." }, - report: "## 旧版点评\nCron 不可用时仍可阅读。", - }); - mocks.defaultLlmConfig.mockReturnValue(null); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ username: "legacy-read-fixture", lang: "zh" }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("Cron 不可用时仍可阅读。"); - expect(mocks.defaultLlmConfig).not.toHaveBeenCalled(); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - expect(mocks.setCachedRoast).not.toHaveBeenCalled(); - }); - - it("uses the current trusted quick scan instead of replaying v5 for a new roast request", async () => { - mocks.getLegacyReadFallbackRoast.mockResolvedValue({ - username: "DemoDev", - final_score: 73, - tier: "人上人", - tags: { zh: ["旧版"], en: ["legacy"] }, - roast_line: { zh: "旧版锐评。", en: "Legacy roast." }, - report: "## 旧版点评\n只读回放。", - }); - mocks.getPublicScanStatus.mockResolvedValue({ - status: "pending", - run: { id: "active-run", username: "DemoDev" }, - retryAfterSeconds: 5, - headStartJobId: null, - }); - mocks.requiresDurablePublicScan.mockReturnValue(true); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("开源活跃度在上升"); - expect(mocks.getLegacyReadFallbackRoast).not.toHaveBeenCalled(); - expect(mocks.getCanonicalScoreWriteIdentity).not.toHaveBeenCalled(); - expect(mocks.updateRoast).not.toHaveBeenCalled(); - }); - - it("skips the legacy artifact when refresh explicitly requests v9 work", async () => { - mocks.getLegacyReadFallbackRoast.mockResolvedValue({ - username: "legacy-read-fixture", - final_score: 73, - tier: "人上人", - tags: { zh: ["旧版"], en: ["legacy"] }, - roast_line: { zh: "旧版锐评。", en: "Legacy roast." }, - report: "## 旧版点评\n只读回放。", - }); - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "legacy-read-fixture", collectionVersion: "v3" }, - scan, - refreshPending: false, - refreshRun: null, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ username: "legacy-read-fixture", lang: "zh", refresh: true }), - }), - ); - - expect(response.status).toBe(409); - expect(mocks.getLegacyReadFallbackRoast).not.toHaveBeenCalled(); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - }); - - it("does not generate or queue a report from stale v3 evidence", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "DemoDev", collectionVersion: "v3" }, - scan, - refreshPending: false, - refreshRun: null, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toMatchObject({ - error: "scan_enrichment_pending", - stale: true, - served_collection_version: "v3", - target_collection_version: "v4", - }); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - }); - - it("limits every roast path before durable-status and scan-cache reads", async () => { - mocks.checkRoastRequestRateLimit.mockResolvedValue({ success: false }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "60" }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ - scan, - lang: "zh", - byoKey: { baseURL: "https://llm.example.test/v1", apiKey: "user-key", model: "test" }, - }), - }), - ); - - expect(response.status).toBe(429); - expect(response.headers.get("Retry-After")).toBe("60"); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - }); - - it("fails closed for BYO roast requests before durable-status and scan-cache reads", async () => { - mocks.checkRoastRequestRateLimit.mockResolvedValue({ success: false, unavailable: true, retryAfter: 15 }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "15" }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ - scan, - lang: "zh", - byoKey: { baseURL: "https://llm.example.test/v1", apiKey: "user-key", model: "test" }, - }), - }), - ); - - expect(response.status).toBe(503); - expect(response.headers.get("Retry-After")).toBe("15"); - await expect(response.json()).resolves.toMatchObject({ error: "rate_limit_unavailable", useByoKey: true }); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - }); - - it("rejects an untrusted body scan on the default model without creating work", async () => { - mocks.getCachedScan.mockResolvedValue(null); - mocks.getPublicScanStatus.mockResolvedValue(null); - mocks.requiresDurablePublicScan.mockReturnValue(true); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ error: "missing_scan" }); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.getCanonicalScoreWriteIdentity).not.toHaveBeenCalled(); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - }); - - it("ignores a forged body scan when a trusted server scan is available", async () => { - const forgedScan: ScanResult = { - ...scan, - scoring: { - ...scan.scoring, - final_score: 99, - tier: "夯", - tier_label: "夯到爆表", - }, - }; - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan: forgedScan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(200); - await response.text(); - expect(mocks.buildRoastMessages).toHaveBeenCalledWith(scan, "zh"); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - }); - - it("allows a BYO body scan only as an immediate non-persistent roast", async () => { +const scan = { + metrics: { username: "DemoDev", profile_url: "https://github.com/DemoDev", avatar_url: null }, + scoring: { final_score: 71, tier: "人上人", tier_label: "trusted", sub_scores: {}, base_score: 71, total_penalty: 0, red_flags: [] }, +} as unknown as ScanResult; + +describe("POST /api/roast quick score contract", () => { + beforeEach(() => { + mocks.checkBotId.mockResolvedValue({ isBot: false, isVerifiedBot: false }); + mocks.checkRoastRequestRateLimit.mockResolvedValue({ success: true }); + mocks.checkRoastRateLimit.mockResolvedValue({ success: true }); + mocks.rateLimitHeaders.mockReturnValue({}); + mocks.defaultLlmConfig.mockReturnValue({ baseURL: "https://llm.example.test", apiKey: "key", model: "model" }); + mocks.fallbackLlmConfig.mockReturnValue(null); mocks.getCachedScan.mockResolvedValue(null); - mocks.getPublicScanStatus.mockResolvedValue(null); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ - scan, - lang: "zh", - byoKey: { baseURL: "https://llm.example.test/v1", apiKey: "user-key", model: "test" }, - }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("开源活跃度在上升"); - expect(mocks.getCanonicalScoreWriteIdentity).not.toHaveBeenCalled(); - expect(mocks.updateRoast).not.toHaveBeenCalled(); - expect(mocks.setCachedRoast).not.toHaveBeenCalled(); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - }); - - it("does not advance an existing durable scan when a roast is retried", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "pending", - run: { id: "active-run", username: "DemoDev" }, - retryAfterSeconds: 5, - headStartJobId: null, + mocks.getCurrentCanonicalQuickScan.mockResolvedValue(null); + mocks.getLegacyReadFallbackRoast.mockResolvedValue(null); + mocks.getCanonicalScoreWriteIdentity.mockResolvedValue({ scannedAt: 1, token: "token" }); + mocks.getCachedRoast.mockResolvedValue(null); + mocks.getArchivedRoast.mockResolvedValue(null); + mocks.getScoreScannedAt.mockResolvedValue(1); + mocks.acquireRoastLock.mockResolvedValue(true); + mocks.updateRoast.mockResolvedValue(true); + mocks.getRankCached.mockResolvedValue(null); + mocks.chat.mockImplementation(async function* () { + yield { type: "content", text: "## 锐评\n工作扎实。" }; }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(409); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - }); - - it("streams a trusted provisional quick roast without creating durable work or a canonical report", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "pending", - run: { id: "active-run", username: "DemoDev" }, - retryAfterSeconds: 5, - headStartJobId: null, - }); - mocks.requiresDurablePublicScan.mockReturnValue(true); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("开源活跃度在上升"); - expect(mocks.getCanonicalScoreWriteIdentity).not.toHaveBeenCalled(); - expect(mocks.updateRoast).not.toHaveBeenCalled(); - expect(mocks.setCachedRoast).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - }); - - it("fails closed before LLM work when the canonical score identity is missing", async () => { - mocks.getCanonicalScoreWriteIdentity.mockResolvedValue(null); - mocks.getCachedRoast.mockResolvedValue({ - report: "## synthetic legacy cache", - delta: 0, - tags: { zh: [], en: [] }, - roast_line: { zh: "", en: "" }, - final_score: 99, - tier: "夯", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toEqual({ error: "score_materialization_pending" }); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - expect(mocks.checkRoastRateLimit).not.toHaveBeenCalled(); - expect(mocks.getCachedRoast).not.toHaveBeenCalled(); - expect(mocks.acquireRoastLock).not.toHaveBeenCalled(); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - expect(mocks.updateRoast).not.toHaveBeenCalled(); - }); - - it("emits one structured summary with request, stream, lock, and provider timings", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - try { - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - await response.text(); - - const summaryCall = log.mock.calls.find(([name]) => name === "roast.summary"); - expect(summaryCall).toBeDefined(); - const summary = JSON.parse(String(summaryCall![1])); - expect(summary).toMatchObject({ - ok: true, - source: "generate", - generationPath: "leader", - lockWaitMs: 0, - requestId: expect.any(String), - }); - expect(summary.u).toBeUndefined(); - expect(JSON.stringify(summary)).not.toContain("DemoDev"); - expect(summary.requestTotalMs).toEqual(expect.any(Number)); - expect(summary.streamMs).toEqual(expect.any(Number)); - expect(summary.firstEventMs).toEqual(expect.any(Number)); - expect(summary.firstContentMs).toEqual(expect.any(Number)); - expect(summary.metaMs).toEqual(expect.any(Number)); - expect(summary.attempts.map((event: { phase: string }) => event.phase)).toEqual([ - "start", - "first_event", - "first_content", - "success", - ]); - } finally { - log.mockRestore(); - } - }); - - it("does not log partial model text, account names, or raw generation errors", async () => { - const rawMarker = "sensitive-upstream-marker"; - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValue( - streamThenFail(`partial output for ${scan.metrics.username}`, new Error(rawMarker)), - ); - const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - try { - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - await response.text(); - - const summaryCall = log.mock.calls.find(([name]) => name === "roast.summary"); - expect(summaryCall).toBeDefined(); - const serialized = String(summaryCall![1]); - expect(serialized).not.toContain(scan.metrics.username); - expect(serialized).not.toContain(rawMarker); - expect(JSON.parse(serialized)).not.toHaveProperty("head"); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - expect(mocks.updateRoast).not.toHaveBeenCalled(); - expect(mocks.setCachedRoast).not.toHaveBeenCalled(); - } finally { - log.mockRestore(); - } }); - it("attaches a fresh default report to the exact materialized score identity", async () => { - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); + it("streams and persists a roast for a persisted quick snapshot without a scan queue", async () => { + const response = await POST(new NextRequest("https://example.test/api/roast", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scan, lang: "zh" }), + })); - await expect(response.text()).resolves.toContain("开源活跃度在上升"); expect(response.status).toBe(200); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - expect(mocks.updateRoast).toHaveBeenCalledWith( - "DemoDev", - expect.stringContaining("## 毒舌点评"), - "zh", - scoreIdentity, - { - tags: { zh: ["进步", "维护者"], en: ["improving", "maintainer"] }, - roastLine: { zh: "稳步进步。", en: "Steady improvement." }, - }, - ); - expect(mocks.chatStreamEvents).toHaveBeenCalledTimes(1); - expect(mocks.getCanonicalScoreWriteIdentity.mock.invocationCallOrder[0]).toBeLessThan( - mocks.chatStreamEvents.mock.invocationCallOrder[0], - ); - }); - - it("does not warm replay caches when a late report loses the score CAS", async () => { - mocks.updateRoast.mockResolvedValue(false); - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - await response.text(); - expect(response.status).toBe(200); - expect(mocks.updateRoast).toHaveBeenCalledWith( - "DemoDev", - expect.stringContaining("## 毒舌点评"), - "zh", - scoreIdentity, - expect.objectContaining({ - tags: { zh: ["进步", "维护者"], en: ["improving", "maintainer"] }, - }), - ); - expect(mocks.setCachedRoast).not.toHaveBeenCalled(); - }); - - it("softens unsupported farming claims for strong core-impact accounts", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 0@@", - "@@TAGS zh=PR刷子,AI代笔|en=PR Spammer,PR Farmer@@", - "@@ROAST zh=靠刷PR混到顶级档位,水分很大。|en=PR Spammer with ghostwriting.@@", - "## 毒舌点评", - "这是低质量贡献刷量,含水量不低,有水分,模板化刷测试覆盖率,批量刷向目标仓库,刷存在感,蹭外部项目,KPI味很重,没混上提交权限,没混上写源码的权限,没有提交权限,没有commit权限,没有commit贡献记录,贡献深度存疑,还AI代笔不嫌丢人。", - ].join("\n"), - ), - ); - const strongCoreScan: ScanResult = { - ...scan, - metrics: { - ...scan.metrics, - core_impact_pr_count: 50, - doc_like_impact_pr_count: 0, - impact_pr_count: 600, - impact_commit_count: 0, - recent_external_doc_like_pr_ratio: 0, - pr_rejection_rate: 0.08, - }, - scoring: { - ...scan.scoring, - final_score: 82.4, - tier: "顶级", - tier_label: "顶级开发者 · 一线水准", - }, - }; - mocks.getCachedScan.mockResolvedValue(strongCoreScan); - mocks.getPublicScanStatus.mockResolvedValue(completePublicStatus(strongCoreScan)); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan: strongCoreScan, lang: "zh" }), - }), - ); - - const body = await response.text(); - expect(body).toContain("模式化贡献"); - expect(body).toContain("争议点"); - expect(body).toContain("批量投向目标仓库"); - expect(body).toContain("借外部项目做曝光"); - expect(body).toContain("依赖外部项目"); - expect(body).toContain("没有直接 commit 信号"); - expect(body).toContain("但 PR 贡献样本足够扎实"); - expect(body).toContain("AI辅助"); - expect(body).not.toContain("PR刷子"); - expect(body).not.toContain("刷"); - expect(body).not.toContain("刷量"); - expect(body).not.toContain("批量刷向"); - expect(body).not.toContain("含水量"); - expect(body).not.toContain("水分"); - expect(body).not.toContain("刷存在感"); - expect(body).not.toContain("蹭外部项目"); - expect(body).not.toContain("蹭"); - expect(body).not.toContain("KPI"); - expect(body).not.toContain("没混上提交权限"); - expect(body).not.toContain("没混上写源码的权限"); - expect(body).not.toContain("没有提交权限"); - expect(body).not.toContain("没有commit权限"); - expect(body).not.toContain("没有commit贡献记录"); - expect(body).not.toContain("贡献深度存疑"); - expect(body).not.toContain("不嫌丢人"); - expect(mocks.updateRoast).toHaveBeenCalledWith( - "DemoDev", - expect.not.stringContaining("刷量"), - "zh", - scoreIdentity, - { - tags: { zh: ["模式PR工", "AI辅助"], en: ["Pattern PR"] }, - roastLine: { - zh: "靠批量提PR站到顶级档位,争议点很大。", - en: "Pattern PR with AI assistance.", - }, - }, - ); - }); - - it("removes internal score-cap phrasing from generated reports", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 0@@", - "@@TAGS zh=普通账号|en=average@@", - "@@ROAST zh=生态证据偏弱。|en=Weak ecosystem evidence.@@", - "## 毒舌点评", - "高星仓库生态影响被硬压到4/20,生态影响被压4/20,按规则扣分,被评分引擎压到了4分,被评分引擎直接压到4/20,被评分引擎压,被评分引擎封顶到低档。", - ].join("\n"), - ), - ); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - const body = await response.text(); - expect(body).toContain("高星仓库生态影响只有4/20"); - expect(body).toContain("数据上吃亏"); - expect(body).toContain("这项表现偏弱"); - expect(body).not.toContain("硬压到"); - expect(body).not.toContain("被压到"); - expect(body).not.toContain("压到了"); - expect(body).not.toContain("按规则扣分"); - expect(body).not.toContain("评分引擎"); - expect(body).not.toContain("被压4/20"); - expect(body).not.toContain("被评分现偏弱"); - expect(body).not.toContain("被有4/20"); - expect(mocks.updateRoast).toHaveBeenCalledWith( - "DemoDev", - expect.not.stringMatching(/硬压到|按规则扣分|评分引擎/u), - "zh", - scoreIdentity, - expect.objectContaining({ - tags: { zh: ["普通账号"], en: ["average"] }, - }), - ); - }); - - it("removes internal score-cap phrasing when streamed across chunks", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamChunks([ - [ - "@@ADJUST 0@@", - "@@TAGS zh=普通账号|en=average@@", - "@@ROAST zh=生态证据偏弱。|en=Weak ecosystem evidence.@@", - "## 毒舌点评", - "生态影响被评分引擎压", - ].join("\n"), - "到了4分,按规则扣分。", - ]), - ); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - const body = await response.text(); - expect(body).toContain("生态影响只有4分"); - expect(body).toContain("数据上吃亏"); - expect(body).not.toContain("评分引擎"); - expect(body).not.toContain("被压到"); - expect(body).not.toContain("压到了"); - expect(body).not.toContain("按规则扣分"); - }); - - it("expands popular-repo shorthand to full owner/repo names in report text", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 0@@", - "@@TAGS zh=生态,贡献|en=ecosystem,impact@@", - "@@ROAST zh=生态贡献很广。|en=Wide ecosystem work.@@", - "## 毒舌点评", - "向15万星的dify和11万星的rust长期提交PR,别再写成裸仓库简称。", - ].join("\n"), - ), - ); - const shorthandScan: ScanResult = { - ...scan, - impact_repos: [ - { repo: "langgenius/dify", stars: 149_000, prs: 3, commits: 0 }, - { repo: "rust-lang/rust", stars: 114_000, prs: 2, commits: 0 }, - ], - }; - mocks.getCachedScan.mockResolvedValue(shorthandScan); - mocks.getPublicScanStatus.mockResolvedValue(completePublicStatus(shorthandScan)); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan: shorthandScan, lang: "zh" }), - }), - ); - - const body = await response.text(); - expect(body).toContain("15万星的langgenius/dify"); - expect(body).toContain("11万星的rust-lang/rust"); - expect(body).not.toContain("15万星的dify"); - expect(body).not.toMatch(/11万星的rust(?!-lang\/rust)/u); - }); - - it("appends varied signature evidence for same-owner small repos", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 0@@", - "@@TAGS zh=生态,修复|en=ecosystem,fixes@@", - "@@ROAST zh=生态贡献有细节。|en=Concrete ecosystem work.@@", - "## 毒舌点评", - "只写了一个普通报告,故意漏掉具体 signature work。", - ].join("\n"), - ), - ); - const signatureScan: ScanResult = { - ...scan, - signature_work: { - source: "all_history_public_scan", - impact_repo_representatives: [], - work_clusters: [ - { - repo: "demo/main-tool", - stars: 120, - all_time_prs: 8, - quality_keyword_hits: 2, - examples: ["fix(runtime): clean stale state"], - }, - { - repo: "demo/control-plane", - stars: 39, - all_time_prs: 5, - quality_keyword_hits: 4, - examples: ["fix(api): revoke bound deployment capabilities"], - org_context_repo: "demo/main-platform", - org_context_stars: 100_000, - substantive_low_star_signal: true, - }, - ], - }, - }; - mocks.getCachedScan.mockResolvedValue(signatureScan); - mocks.getPublicScanStatus.mockResolvedValue(completePublicStatus(signatureScan)); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan: signatureScan, lang: "zh" }), - }), - ); - - const body = await response.text(); - expect(body).toContain("**补充证据**"); - expect(body).toContain("额外可核对的活动还包括 demo/main-tool"); - expect(body).toContain("demo/control-plane: 5 个 PR 不是孤立小仓库劳动"); - expect(body).toContain("demo/main-platform"); - expect(body).not.toContain("全量公开扫描还抓到"); + await new Response(response.body).text(); + expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith("DemoDev", expect.stringMatching(/^[a-f0-9]{64}$/)); + expect(mocks.updateRoast).toHaveBeenCalled(); }); - it("writes an English roast in one model call without accepting model score changes", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 3@@", - "@@TAGS zh=进步,维护者|en=improving,maintainer@@", - "@@ROAST zh=稳步进步。|en=Steady improvement.@@", - "## Roast", - "Open-source activity is rising.", - ].join("\n"), - ), - ); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "en" }), - }), - ); - - await expect(response.text()).resolves.toContain("Open-source activity is rising"); - expect(response.status).toBe(200); - expect(mocks.chatStreamEvents).toHaveBeenCalledTimes(1); - expect(mocks.buildRoastMessages).toHaveBeenCalledWith( - expect.anything(), - "en", - ); - expect(mocks.updateRoast).toHaveBeenCalledWith( - "DemoDev", - expect.stringContaining("## Roast"), - "en", - scoreIdentity, - expect.objectContaining({ - roastLine: { zh: "稳步进步。", en: "Steady improvement." }, - }), - ); - }); - - it("clamps overlong top-roast lines without cutting English mid-word", async () => { - mocks.chatStreamEvents.mockReset(); - mocks.chatStreamEvents.mockReturnValueOnce( - streamText( - [ - "@@ADJUST 0@@", - "@@TAGS zh=进步,维护者|en=improving,maintainer@@", - "@@ROAST zh=外部贡献很勤快,但自家项目像没人认领。|en=38 followers versus 81 following, 109 PRs live in other people's repos before 相关仓库贡献者s can reject, while the home project has 1 star and an ‘unmistakablyunfinishedword at the cutoff.@@", - "## 毒舌点评", - "开源活跃度在上升。", - ].join("\n"), - ), - ); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - await response.text(); - expect(response.status).toBe(200); - const persistenceMeta = mocks.updateRoast.mock.calls[0][4]; - const en = persistenceMeta.roastLine.en; - expect(Array.from(en).length).toBeLessThanOrEqual(180); - expect(en).toMatch(/[.!?…]$/u); - expect(en).not.toMatch(/\p{Script=Han}/u); - expect(en).toContain("maintainers"); - expect(en).not.toContain("‘"); - }); - - it("replays a canonical archive with deterministic zero delta", async () => { - mocks.getArchivedRoast.mockResolvedValue({ - username: "demodev", - final_score: 79, - tier: "人上人", - tags: { zh: ["存档"], en: ["archived"] }, - roast_line: { zh: "存档锐评。", en: "Archived roast." }, - report: "## 存档点评\n正式版本内容。", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("正式版本内容"); - const encodedMeta = response.headers.get("X-Roast-Meta"); - expect(encodedMeta).not.toBeNull(); - const meta = JSON.parse(Buffer.from(encodedMeta!, "base64").toString("utf8")); - expect(meta).toMatchObject({ final_score: 79, delta: 0 }); - expect(mocks.setCachedRoast).toHaveBeenCalledWith( - "DemoDev", - "zh", - expect.objectContaining({ final_score: 79, delta: 0 }), - ); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - }); - - it("ignores refresh for a still-fresh roast and replays the cache instead", async () => { - mocks.getScoreScannedAt.mockResolvedValue(Date.now() - 60 * 60 * 1000); // 1h ago - mocks.getCachedRoast.mockResolvedValue({ - report: "## 缓存点评\n仍然新鲜。", - snapshot_hash: canonicalSnapshotHash, - delta: 0, - tags: { zh: ["缓存"], en: ["cached"] }, - roast_line: { zh: "缓存的。", en: "Cached." }, - final_score: 71, - tier: "人上人", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh", refresh: true }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("仍然新鲜"); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - expect(mocks.clearCachedRoast).not.toHaveBeenCalled(); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - }); - - it("rejects and clears a roast cache from a different scan snapshot", async () => { - mocks.getCachedRoast.mockResolvedValue({ - report: "## 旧快照点评\n不能复用。", - snapshot_hash: "cd".repeat(32), - delta: 0, - tags: { zh: ["旧缓存"], en: ["old-cache"] }, - roast_line: { zh: "旧缓存。", en: "Old cache." }, - final_score: 71, - tier: "人上人", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh" }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.text()).resolves.not.toContain("不能复用"); - expect(mocks.clearCachedRoast).toHaveBeenCalledWith("DemoDev", "zh"); - expect(mocks.chatStreamEvents).toHaveBeenCalledTimes(1); - }); - - it("honors refresh for a stale roast: skips replay paths, clears the cache, regenerates", async () => { - mocks.getScoreScannedAt.mockResolvedValue(Date.now() - 25 * 60 * 60 * 1000); // 25h ago - // Both replay sources would hit — refresh must skip them anyway. - mocks.getCachedRoast.mockResolvedValue({ - report: "## 旧缓存\n过期内容。", - delta: 0, - tags: { zh: [], en: [] }, - roast_line: { zh: "", en: "" }, - final_score: 71, - tier: "人上人", - }); - mocks.getArchivedRoast.mockResolvedValue({ - username: "DemoDev", - final_score: 71, - tier: "人上人", - tags: { zh: [], en: [] }, - roast_line: { zh: "", en: "" }, - report: "## 旧存档\n过期内容。", - }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh", refresh: true }), - }), - ); - - expect(response.status).toBe(200); - const text = await response.text(); - expect(text).toContain("开源活跃度在上升"); - expect(text).not.toContain("过期内容"); - expect(mocks.getCachedRoast).not.toHaveBeenCalled(); - expect(mocks.getArchivedRoast).not.toHaveBeenCalled(); - expect(mocks.clearCachedRoast).toHaveBeenCalledWith("DemoDev", "zh"); - expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith( - "DemoDev", - canonicalSnapshotHash, - ); - }); - - it("drops malformed nested README summaries from client fallback scans", async () => { - mocks.getCachedScan.mockResolvedValue(null); - mocks.getPublicScanStatus.mockResolvedValue(null); - const malformedScan = { - ...scan, - top_repos: [ - { - readme_excerpt: "Fallback summary", - readme: { - features: { - prompt_summary: 42, - }, - }, - }, - ], - } as unknown as ScanResult; - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ - scan: malformedScan, - lang: "zh", - byoKey: { baseURL: "https://llm.example.test/v1", apiKey: "user-key", model: "test" }, - }), - }), - ); - - expect(response.status).toBe(200); - // Generation runs inside the streamed response's start() callback, so - // buildRoastMessages is only invoked once the body is - // consumed — drain it before inspecting the mock. - await response.text(); - const passedScan = mocks.buildRoastMessages.mock.calls[0]![0]; - expect(passedScan.top_repos[0].readme).toBeUndefined(); - expect(passedScan.top_repos[0].readme_excerpt).toBe("Fallback summary"); - }); -}); - -describe("roast API human gate", () => { - // The gate must key on the RESOLVED config: an incomplete byoKey falls back - // to the operator-paid default, so it has to pass BotID exactly like no key. - it("runs the BotID check when byoKey is present but incomplete (falls back to default)", async () => { - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh", byoKey: {} }), - }), - ); - - expect(response.status).toBe(200); - await response.text(); - expect(mocks.checkBotId).toHaveBeenCalledOnce(); - }); - - it("refuses an unverified bot carrying an empty byoKey instead of spending default credit", async () => { - mocks.checkBotId.mockResolvedValueOnce({ isBot: true, isVerifiedBot: false }); - - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ scan, lang: "zh", byoKey: {} }), - }), - ); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toMatchObject({ error: "bot_detected" }); - expect(mocks.chatStreamEvents).not.toHaveBeenCalled(); - }); + it("uses the exact server quick snapshot instead of a client handoff", async () => { + const snapshotHash = "a".repeat(64); + mocks.getCurrentCanonicalQuickScan.mockResolvedValue({ scan, snapshotHash }); - it("skips the BotID check for a complete byoKey (the user pays their own bill)", async () => { - const response = await POST( - new NextRequest("https://example.test/api/roast", { - method: "POST", - body: JSON.stringify({ - scan, - lang: "zh", - byoKey: { apiKey: "user-key", baseURL: "https://user-llm.test/v1", model: "m" }, - }), - }), - ); + const response = await POST(new NextRequest("https://example.test/api/roast", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scan: { ...scan, scoring: { ...scan.scoring, final_score: 1 } }, lang: "zh" }), + })); expect(response.status).toBe(200); - await response.text(); - expect(mocks.checkBotId).not.toHaveBeenCalled(); + await new Response(response.body).text(); + expect(mocks.getCanonicalScoreWriteIdentity).toHaveBeenCalledWith("DemoDev", snapshotHash); }); }); diff --git a/src/app/api/roast/route.ts b/src/app/api/roast/route.ts index 9c83475c..e3078abe 100644 --- a/src/app/api/roast/route.ts +++ b/src/app/api/roast/route.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { checkBotId } from "botid/server"; import { TIER_LABEL_EN } from "@/lib/badge"; @@ -6,6 +6,7 @@ import { machineAuth } from "@/lib/machine-auth"; import { getArchivedRoast, getCanonicalScoreWriteIdentity, + getCurrentCanonicalQuickScan, getLegacyReadFallbackRoast, getScoreScannedAt, updateRoast, @@ -27,10 +28,6 @@ import { beatPercent } from "@/lib/percentile"; import { buildRoastMessages } from "@/lib/prompt"; import { reportMatchesLang } from "@/lib/report"; import { sanitizeIdentityClaims } from "@/lib/identity"; -import { - getPublicScanStatus, - requiresDurablePublicScan, -} from "@/lib/public-scan"; import { acquireRoastLock, checkRoastRequestRateLimit, @@ -628,8 +625,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } - // This protects every path, including BYO: it runs before the durable-status - // and scan-cache reads below, while the later roast limiter remains dedicated + // This protects every path, including BYO: it runs before the snapshot and + // scan-cache reads below, while the later roast limiter remains dedicated // to operator-paid model generation. const requestLimit = await checkRoastRequestRateLimit(clientIp(req)); if (!requestLimit.success) { @@ -644,8 +641,8 @@ export async function POST(req: NextRequest) { const lang = normLang(body.lang); - // A verified v5/v5/v3 artifact is a read-only continuity path for a stalled - // v9 collector. It is intentionally checked before resolving an LLM config: + // A verified v5/v5/v3 artifact is a read-only continuity path when the quick + // collector is unavailable. It is intentionally checked before resolving an LLM config: // replaying already-persisted public text must not depend on model capacity or // spend credit. `refresh` explicitly opts out and continues toward v9 work. if (body.refresh !== true && !body.scan) { @@ -702,151 +699,101 @@ export async function POST(req: NextRequest) { // know to release it. Only the default model coalesces (BYO keys self-serve). let isLeader = false; let lockWaitMs = 0; - let generationPath: "leader" | "follower_fallback" | "provisional" | "byo" = isDefault + let generationPath: "leader" | "follower_fallback" | "byo" = isDefault ? "leader" : "byo"; let scoreIdentity: ScoreWriteIdentity | null = null; - // Prefer a durable/current server snapshot. A request-body scan is accepted - // only for an immediate BYO roast. Default-model generation and every durable - // write require facts previously produced by a server-side collector. - const [publicStatus, cachedScan] = await Promise.all([ - getPublicScanStatus(username), + // `/api/scan` materializes the bounded quick snapshot before the client gets + // it. Default-model reports always prefer the exact server snapshot joined to + // the currently served v9 score. There is no background-job dependency. + const [canonicalQuick, cachedScan] = await Promise.all([ + getCurrentCanonicalQuickScan(username), getCachedScan(username), ]); - const completedPublicRun = publicStatus?.status === "complete" ? publicStatus.run : null; - const completedPublicScan = publicStatus?.status === "complete" ? publicStatus.scan : null; - // This is the only incomplete server scan that may reach the default model. - // It is server-authored by the bounded quick collector and the same result - // has already determined that a full-history job is needed. The response is - // transient: no canonical score/report cache or database row is touched. - const provisional = Boolean( - cachedScan && !completedPublicScan && requiresDurablePublicScan(cachedScan), - ); - if (publicStatus?.status === "stale" && !provisional) { - return NextResponse.json( - { - error: "scan_enrichment_pending", - username, - stale: true, - refresh_pending: publicStatus.refreshPending, - served_collection_version: publicStatus.servedCollectionVersion, - target_collection_version: publicStatus.targetCollectionVersion, - }, - { status: 409, headers: { "Cache-Control": "no-store" } }, - ); - } - const serverScan = completedPublicScan ?? cachedScan; - const scan = serverScan ?? (!isDefault && body.scan ? sanitizeScan(body.scan) : null); + const scan = canonicalQuick?.scan ?? cachedScan ?? (body.scan ? sanitizeScan(body.scan) : null); if (!scan?.metrics || !scan.scoring) { return NextResponse.json({ error: "missing_scan" }, { status: 400 }); } - - // `/api/scan` owns durable job creation. A roast request must never create - // or head-start shared collection work; it either uses the trusted bounded - // result above, or asks the caller to return through the scan endpoint. - if (serverScan && !completedPublicScan && !provisional) { - const retryAfterSeconds = publicStatus && "retryAfterSeconds" in publicStatus - ? publicStatus.retryAfterSeconds - : 5; - return NextResponse.json( - { - error: "scan_enrichment_pending", - username, - ...(publicStatus?.status === "pending" ? { run_id: publicStatus.run.id } : {}), - retry_after: retryAfterSeconds, - }, - { - status: 409, - headers: { "Retry-After": String(retryAfterSeconds), "Cache-Control": "no-store" }, - }, - ); - } + const snapshotHash = + canonicalQuick?.snapshotHash ?? createHash("sha256").update(JSON.stringify(scan)).digest("hex"); // Default-model protections: serve a cached roast for free, else rate-limit the // (credit-spending) LLM call. BYO keys skip both — it's the user's own credit. if (isDefault) { let refreshHonored = false; - if (!provisional) { - // Every replay and new formal report must belong to the deterministic - // score from this exact canonical snapshot. - const snapshotHash = completedPublicRun?.snapshotHash; - if (!snapshotHash) { - return NextResponse.json( - { error: "score_materialization_pending" }, - { status: 409, headers: { "Cache-Control": "no-store" } }, - ); - } - try { - scoreIdentity = await getCanonicalScoreWriteIdentity(username, snapshotHash); - } catch { - scoreIdentity = null; - } - if (!scoreIdentity) { - return NextResponse.json( - { error: "score_materialization_pending" }, - { status: 409, headers: { "Cache-Control": "no-store" } }, - ); - } + // Every replay and new report belongs to the deterministic v9 score from + // this exact quick snapshot. A forged request body cannot produce a report + // because it has no matching score write identity. + try { + scoreIdentity = await getCanonicalScoreWriteIdentity(username, snapshotHash); + } catch { + scoreIdentity = null; + } + if (!scoreIdentity) { + return NextResponse.json( + { error: "score_materialization_pending" }, + { status: 409, headers: { "Cache-Control": "no-store" } }, + ); + } - // A validated `refresh` skips canonical replay paths only when the - // stored report is actually stale. - if (body.refresh === true) { - const scannedAt = await getScoreScannedAt(username); - refreshHonored = scannedAt == null || Date.now() - scannedAt > ROAST_FRESH_MS; - } + // A validated `refresh` skips canonical replay paths only when the + // stored report is actually stale. + if (body.refresh === true) { + const scannedAt = await getScoreScannedAt(username); + refreshHonored = scannedAt == null || Date.now() - scannedAt > ROAST_FRESH_MS; + } - if (!refreshHonored) { - const cachedRoast = await getCachedRoast(username, lang); - if ( - cachedRoast?.snapshot_hash === snapshotHash && - reportMatchesLang(cachedRoast.report, lang) - ) { - const tags = cachedRoast.tags ?? { zh: [], en: [] }; - const roastLine = cachedRoast.roast_line ?? EMPTY_ROAST_LINE; - const meta = - cachedRoast.final_score !== undefined && cachedRoast.tier - ? await metaForStoredRoast( - cachedRoast.final_score, - cachedRoast.tier, - tags, - roastLine, - lang, - ) - : await computeMeta(scan, cachedRoast.delta, tags, roastLine, lang); - logRoastSummary({ - requestId, lang, path, ok: true, source: "redis_cache", - requestTotalMs: Date.now() - reqT0, - }); - return roastResponse(cachedRoast.report, meta); - } - if (cachedRoast) await clearCachedRoast(username, lang); + if (!refreshHonored) { + const cachedRoast = await getCachedRoast(username, lang); + if ( + cachedRoast?.snapshot_hash === snapshotHash && + reportMatchesLang(cachedRoast.report, lang) + ) { + const tags = cachedRoast.tags ?? { zh: [], en: [] }; + const roastLine = cachedRoast.roast_line ?? EMPTY_ROAST_LINE; + const meta = + cachedRoast.final_score !== undefined && cachedRoast.tier + ? await metaForStoredRoast( + cachedRoast.final_score, + cachedRoast.tier, + tags, + roastLine, + lang, + ) + : await computeMeta(scan, cachedRoast.delta, tags, roastLine, lang); + logRoastSummary({ + requestId, lang, path, ok: true, source: "redis_cache", + requestTotalMs: Date.now() - reqT0, + }); + return roastResponse(cachedRoast.report, meta); + } + if (cachedRoast) await clearCachedRoast(username, lang); - const archivedRoast = await getArchivedRoast(username, lang); - if (archivedRoast && reportMatchesLang(archivedRoast.report, lang)) { - const meta = await metaForStoredRoast( - archivedRoast.final_score, - archivedRoast.tier, - archivedRoast.tags, - archivedRoast.roast_line, - lang, - ); - await cacheRoastReplay( - username, - lang, - snapshotHash, - archivedRoast.report, - archivedRoast.tags, - archivedRoast.roast_line, - archivedRoast.final_score, - archivedRoast.tier, - ); - logRoastSummary({ - requestId, lang, path, ok: true, source: "archive", - requestTotalMs: Date.now() - reqT0, - }); - return roastResponse(archivedRoast.report, meta); - } + const archivedRoast = await getArchivedRoast(username, lang); + if (archivedRoast && reportMatchesLang(archivedRoast.report, lang)) { + const meta = await metaForStoredRoast( + archivedRoast.final_score, + archivedRoast.tier, + archivedRoast.tags, + archivedRoast.roast_line, + lang, + ); + await cacheRoastReplay( + username, + lang, + snapshotHash, + archivedRoast.report, + archivedRoast.tags, + archivedRoast.roast_line, + archivedRoast.final_score, + archivedRoast.tier, + ); + logRoastSummary({ + requestId, lang, path, ok: true, source: "archive", + requestTotalMs: Date.now() - reqT0, + }); + return roastResponse(archivedRoast.report, meta); } } const generationLimit = await checkRoastRateLimit(clientIp(req)); @@ -859,43 +806,36 @@ export async function POST(req: NextRequest) { }, ); } - if (provisional) { - generationPath = "provisional"; + isLeader = await acquireRoastLock(username, lang); + if (isLeader) { + if (refreshHonored) await clearCachedRoast(username, lang); } else { - const snapshotHash = completedPublicRun!.snapshotHash!; - // Single-flight is only for a canonical artifact. A provisional report - // has no stable snapshot identity and must not be replayed. - isLeader = await acquireRoastLock(username, lang); - if (isLeader) { - if (refreshHonored) await clearCachedRoast(username, lang); - } else { - const lockWaitStartedAt = Date.now(); - const shared = await waitForCachedRoast(username, lang); - lockWaitMs = Date.now() - lockWaitStartedAt; - if ( - shared?.snapshot_hash === snapshotHash && - reportMatchesLang(shared.report, lang) - ) { - const tags = shared.tags ?? { zh: [], en: [] }; - const roastLine = shared.roast_line ?? EMPTY_ROAST_LINE; - const meta = - shared.final_score !== undefined && shared.tier - ? await metaForStoredRoast( - shared.final_score, - shared.tier, - tags, - roastLine, - lang, - ) - : await computeMeta(scan, shared.delta, tags, roastLine, lang); - logRoastSummary({ - requestId, lang, path, ok: true, source: "singleflight_shared", - lockWaitMs, requestTotalMs: Date.now() - reqT0, - }); - return roastResponse(shared.report, meta); - } - generationPath = "follower_fallback"; + const lockWaitStartedAt = Date.now(); + const shared = await waitForCachedRoast(username, lang); + lockWaitMs = Date.now() - lockWaitStartedAt; + if ( + shared?.snapshot_hash === snapshotHash && + reportMatchesLang(shared.report, lang) + ) { + const tags = shared.tags ?? { zh: [], en: [] }; + const roastLine = shared.roast_line ?? EMPTY_ROAST_LINE; + const meta = + shared.final_score !== undefined && shared.tier + ? await metaForStoredRoast( + shared.final_score, + shared.tier, + tags, + roastLine, + lang, + ) + : await computeMeta(scan, shared.delta, tags, roastLine, lang); + logRoastSummary({ + requestId, lang, path, ok: true, source: "singleflight_shared", + lockWaitMs, requestTotalMs: Date.now() - reqT0, + }); + return roastResponse(shared.report, meta); } + generationPath = "follower_fallback"; } } @@ -951,7 +891,7 @@ export async function POST(req: NextRequest) { requestId, lang, path, - source: provisional ? "quick_provisional" : "generate", + source: "generate", generationPath, lockWaitMs, streamMs: Date.now() - t0, @@ -1071,7 +1011,7 @@ export async function POST(req: NextRequest) { } // Persist under the exact score-write identity before warming replay. // A late report that loses the CAS must not enter either storage layer. - if (!provisional && isDefault && reportMatchesLang(full, lang)) { + if (isDefault && reportMatchesLang(full, lang)) { const persisted = scoreIdentity ? await updateRoast(username, full, lang, scoreIdentity, { tags, roastLine }) : false; @@ -1079,7 +1019,7 @@ export async function POST(req: NextRequest) { await cacheRoastReplay( username, lang, - completedPublicRun!.snapshotHash!, + snapshotHash, full, tags, roastLine, diff --git a/src/app/api/route.ts b/src/app/api/route.ts index c8fedf50..422a7731 100644 --- a/src/app/api/route.ts +++ b/src/app/api/route.ts @@ -26,7 +26,6 @@ export async function GET() { endpoints: [ `GET ${SITE_URL}/api/score/{username}`, `POST ${SITE_URL}/api/scan`, - `GET ${SITE_URL}/api/scan-status/{username}?run_id={run_id}`, `POST ${SITE_URL}/api/roast`, `POST ${SITE_URL}/api/vs-verdict`, `GET ${SITE_URL}/api/leaderboard`, diff --git a/src/app/api/scan-status/[username]/route.test.ts b/src/app/api/scan-status/[username]/route.test.ts deleted file mode 100644 index 152b1e5e..00000000 --- a/src/app/api/scan-status/[username]/route.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - getPublicScanStatus: vi.fn(), - checkPublicScanStatusRateLimit: vi.fn(), - rateLimitHeaders: vi.fn(), -})); - -vi.mock("@/lib/public-scan", () => ({ - getPublicScanStatus: mocks.getPublicScanStatus, -})); - -vi.mock("@/lib/redis", () => ({ - checkPublicScanStatusRateLimit: mocks.checkPublicScanStatusRateLimit, - rateLimitHeaders: mocks.rateLimitHeaders, -})); - -import { GET } from "./route"; - -function request(username: string, runId = "run-id") { - return GET(new NextRequest(`https://example.test/api/scan-status/${username}?run_id=${runId}`), { - params: Promise.resolve({ username }), - }); -} - -describe("durable scan status API", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.checkPublicScanStatusRateLimit.mockResolvedValue({ success: true }); - mocks.rateLimitHeaders.mockReturnValue({}); - }); - - it("never creates work when no durable scan was requested", async () => { - mocks.getPublicScanStatus.mockResolvedValue(null); - - const response = await request("durable-status-case"); - - expect(response.status).toBe(404); - await expect(response.json()).resolves.toEqual({ error: "scan_not_found" }); - }); - - it("requires the opaque run id before it reads a durable scan", async () => { - const response = await GET(new NextRequest("https://example.test/api/scan-status/durable-status-case"), { - params: Promise.resolve({ username: "durable-status-case" }), - }); - - expect(response.status).toBe(400); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - }); - - it("rate-limits repeated status reads before they reach Turso", async () => { - mocks.checkPublicScanStatusRateLimit.mockResolvedValue({ success: false }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "60" }); - - const response = await request("durable-status-case"); - - expect(response.status).toBe(429); - expect(response.headers.get("Retry-After")).toBe("60"); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - }); - - it("returns a retryable 503 before Turso when request protection is unavailable", async () => { - mocks.checkPublicScanStatusRateLimit.mockResolvedValue({ success: false, unavailable: true, retryAfter: 15 }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "15" }); - - const response = await request("durable-status-case"); - - expect(response.status).toBe(503); - expect(response.headers.get("Retry-After")).toBe("15"); - await expect(response.json()).resolves.toEqual({ error: "rate_limit_unavailable", retry_after: 15 }); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - }); - - it("returns a complete snapshot only after public collection finishes", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "complete", - run: { id: "run-id", username: "durable-status-case" }, - scan: { metrics: { username: "durable-status-case" } }, - }); - - const response = await request("durable-status-case"); - - expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toContain("s-maxage=60"); - await expect(response.json()).resolves.toEqual({ - status: "complete_public", - username: "durable-status-case", - run_id: "run-id", - scan: { metrics: { username: "durable-status-case" } }, - }); - }); - - it("keeps a v3 snapshot readable while polling its v4 refresh run", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "durable-status-case", collectionVersion: "v3" }, - scan: { metrics: { username: "durable-status-case" } }, - refreshPending: true, - refreshRun: { id: "run-id", username: "durable-status-case", collectionVersion: "v4" }, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - - const response = await request("durable-status-case"); - - expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toContain("s-maxage=5"); - await expect(response.json()).resolves.toMatchObject({ - status: "stale_public", - run_id: "run-id", - stale: true, - refresh_pending: true, - served_collection_version: "v3", - target_collection_version: "v4", - }); - }); - - it("uses retryable pending and failed states without publishing a partial scan", async () => { - mocks.getPublicScanStatus.mockResolvedValueOnce({ - status: "pending", - run: { id: "run-id", username: "durable-status-case" }, - retryAfterSeconds: 7, - }); - const pending = await request("durable-status-case"); - expect(pending.status).toBe(202); - expect(pending.headers.get("Retry-After")).toBe("7"); - expect(pending.headers.get("Cache-Control")).toContain("s-maxage=5"); - await expect(pending.json()).resolves.toMatchObject({ status: "pending", run_id: "run-id" }); - - mocks.getPublicScanStatus.mockResolvedValueOnce({ - status: "failed", - run: { id: "run-id", username: "durable-status-case" }, - retryAfterSeconds: 30, - }); - const failed = await request("durable-status-case"); - expect(failed.status).toBe(503); - await expect(failed.json()).resolves.toMatchObject({ error: "durable_scan_failed" }); - }); -}); diff --git a/src/app/api/scan-status/[username]/route.ts b/src/app/api/scan-status/[username]/route.ts deleted file mode 100644 index 1937d9d5..00000000 --- a/src/app/api/scan-status/[username]/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getPublicScanStatus } from "@/lib/public-scan"; -import { checkPublicScanStatusRateLimit, rateLimitHeaders } from "@/lib/redis"; -import { normalizeUsername } from "@/lib/username"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -const PENDING_CACHE = "public, max-age=0, s-maxage=5, stale-while-revalidate=15"; -const COMPLETE_CACHE = "public, max-age=0, s-maxage=60, stale-while-revalidate=300"; - -function clientIp(req: NextRequest): string { - return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0"; -} - -/** - * Poll only a previously queued durable collection. This endpoint never starts - * a GitHub scan, so browsers and agents can safely wait without creating work. - */ -export async function GET( - req: NextRequest, - ctx: { params: Promise<{ username: string }> }, -) { - const { username } = await ctx.params; - const handle = normalizeUsername(decodeURIComponent(username ?? "")); - if (!handle) return NextResponse.json({ error: "invalid_username" }, { status: 400 }); - - // The initiating response carries this opaque id. Requiring it keeps a public - // username from becoming an unbounded status-read target for arbitrary bots. - const runId = req.nextUrl.searchParams.get("run_id")?.trim(); - if (!runId) return NextResponse.json({ error: "run_id_required" }, { status: 400 }); - - const limit = await checkPublicScanStatusRateLimit(clientIp(req)); - const headers = rateLimitHeaders(limit); - if (!limit.success) { - return NextResponse.json( - { - error: limit.unavailable ? "rate_limit_unavailable" : "rate_limited", - retry_after: Number(headers["Retry-After"] ?? 1), - }, - { status: limit.unavailable ? 503 : 429, headers: { ...headers, "Cache-Control": "no-store" } }, - ); - } - - const status = await getPublicScanStatus(handle); - const activeRun = status?.status === "stale" ? status.refreshRun ?? status.run : status?.run; - if (!status || !activeRun || activeRun.id !== runId) { - return NextResponse.json({ error: "scan_not_found" }, { status: 404, headers }); - } - if (status.status === "complete") { - return NextResponse.json( - { status: "complete_public", username: status.run.username, run_id: status.run.id, scan: status.scan }, - { headers: { ...headers, "Cache-Control": COMPLETE_CACHE } }, - ); - } - if (status.status === "stale") { - return NextResponse.json( - { - status: "stale_public", - username: status.run.username, - run_id: activeRun.id, - scan: status.scan, - stale: true, - refresh_pending: status.refreshPending, - served_collection_version: status.servedCollectionVersion, - target_collection_version: status.targetCollectionVersion, - }, - { - headers: { - ...headers, - "Cache-Control": status.refreshPending ? PENDING_CACHE : COMPLETE_CACHE, - }, - }, - ); - } - return NextResponse.json( - { - status: status.status, - username: status.run?.username ?? handle, - run_id: activeRun.id, - retry_after: status.retryAfterSeconds, - ...(status.status === "failed" ? { error: "durable_scan_failed" } : {}), - }, - { - status: status.status === "failed" ? 503 : 202, - headers: { - ...headers, - "Cache-Control": status.status === "failed" ? "no-store" : PENDING_CACHE, - "Retry-After": String(status.retryAfterSeconds), - }, - }, - ); -} diff --git a/src/app/api/scan/route.test.ts b/src/app/api/scan/route.test.ts index 3491a416..f9570b59 100644 --- a/src/app/api/scan/route.test.ts +++ b/src/app/api/scan/route.test.ts @@ -1,512 +1,98 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ScanResult } from "@/lib/types"; const mocks = vi.hoisted(() => ({ - collect: vi.fn(), - score: vi.fn(), - verifyTurnstile: vi.fn(), - ensureCanonicalScoreForPublicRun: vi.fn(), - hasLegacyReadFallbackProfile: vi.fn(), - getLegacyReadFallbackScan: vi.fn(), - publishCompleteQuickScan: vi.fn(), - recordAccountLookup: vi.fn(), - getLatestPublicScanRun: vi.fn(), + buildScanResult: vi.fn(), checkRateLimit: vi.fn(), - rateLimitHeaders: vi.fn(), coalesceScan: vi.fn(), getCachedScan: vi.fn(), - clearCachedScan: vi.fn(), - setCachedScan: vi.fn(), - getPublicScanStatus: vi.fn(), - publicScanAdmission: vi.fn(() => ({ bucket: "test", limit: 2, windowMs: 60_000, maxActiveJobs: 24 })), - requiresDurablePublicScan: vi.fn(), - resolvePublicScanFromTrustedQuickScan: vi.fn(), - startPublicScan: vi.fn(), - kickPublicScanDrain: vi.fn(), + getLegacyReadFallbackScan: vi.fn(), + hasLegacyReadFallbackProfile: vi.fn(), + publishCompleteQuickScan: vi.fn(), + rateLimitHeaders: vi.fn(), + recordAccountLookup: vi.fn(), + recordCampaignParticipant: vi.fn(), + verifyTurnstile: vi.fn(), })); vi.mock("@/lib/db", () => ({ - ensureCanonicalScoreForPublicRun: mocks.ensureCanonicalScoreForPublicRun, - hasLegacyReadFallbackProfile: mocks.hasLegacyReadFallbackProfile, getLegacyReadFallbackScan: mocks.getLegacyReadFallbackScan, + hasLegacyReadFallbackProfile: mocks.hasLegacyReadFallbackProfile, publishCompleteQuickScan: mocks.publishCompleteQuickScan, recordAccountLookup: mocks.recordAccountLookup, - getLatestPublicScanRun: mocks.getLatestPublicScanRun, + recordCampaignParticipant: mocks.recordCampaignParticipant, })); - -vi.mock("@/lib/github", () => { - class AccountNotFoundError extends Error {} - class GitHubAuthRequiredError extends Error {} - class GitHubDataUnavailableError extends Error {} - class GitHubRateLimitError extends Error {} - return { - AccountNotFoundError, - GitHubAuthRequiredError, - GitHubDataUnavailableError, - GitHubRateLimitError, - collect: mocks.collect, - }; -}); - vi.mock("@/lib/redis", () => ({ checkRateLimit: mocks.checkRateLimit, - rateLimitHeaders: mocks.rateLimitHeaders, coalesceScan: mocks.coalesceScan, - clearCachedScan: mocks.clearCachedScan, getCachedScan: mocks.getCachedScan, - setCachedScan: mocks.setCachedScan, -})); - -vi.mock("@/lib/public-scan", () => ({ - getPublicScanStatus: mocks.getPublicScanStatus, - publicScanAdmission: mocks.publicScanAdmission, - requiresDurablePublicScan: mocks.requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan: mocks.resolvePublicScanFromTrustedQuickScan, - startPublicScan: mocks.startPublicScan, -})); - -vi.mock("@/lib/public-scan-dispatcher", () => ({ - kickPublicScanDrain: mocks.kickPublicScanDrain, -})); - -vi.mock("@/lib/score", () => ({ - score: mocks.score, -})); - -vi.mock("@/lib/turnstile", () => ({ - verifyTurnstile: mocks.verifyTurnstile, + rateLimitHeaders: mocks.rateLimitHeaders, })); +vi.mock("@/lib/scan-core", () => ({ buildScanResult: mocks.buildScanResult })); +vi.mock("@/lib/turnstile", () => ({ verifyTurnstile: mocks.verifyTurnstile })); import { POST } from "./route"; -const originalCliKey = process.env.GITHUB_ROAST_CLI_API_KEY; +const quickScan = { + metrics: { username: "DemoDev", profile_url: "https://github.com/DemoDev", avatar_url: null }, + scoring: { final_score: 71, tier: "人上人", tier_label: "trusted", sub_scores: {}, base_score: 71, total_penalty: 0, red_flags: [] }, +} as unknown as ScanResult; -const metrics = { - username: "DemoDev", - profile_url: "https://github.com/DemoDev", - avatar_url: "https://avatars.githubusercontent.com/u/1", -}; - -const scoring = { - sub_scores: { - account_maturity: 1, - original_project_quality: 2, - contribution_quality: 3, - ecosystem_impact: 4, - community_influence: 5, - activity_authenticity: 6, - }, - base_score: 21, - red_flags: [], - total_penalty: 0, - final_score: 21, - tier: "NPC", - tier_label: "普通账号 · 特征平庸存疑", -}; - -function request(init?: { token?: string; auth?: string }): NextRequest { +function request() { return new NextRequest("https://example.test/api/scan", { method: "POST", - headers: { - "content-type": "application/json", - ...(init?.auth ? { authorization: init.auth } : {}), - }, - body: JSON.stringify({ username: "DemoDev", turnstileToken: init?.token }), + headers: { "content-type": "application/json", authorization: "Bearer test-key" }, + body: JSON.stringify({ username: "DemoDev" }), }); } -describe("scan route machine auth", () => { +describe("POST /api/scan immediate quick contract", () => { beforeEach(() => { - process.env.GITHUB_ROAST_CLI_API_KEY = "cli-secret"; - mocks.collect.mockResolvedValue({ - metrics, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - impact_repos: [], - verified_impact_prs: [], - pinned_repos: [], - organizations: [], - }); - mocks.score.mockReturnValue(scoring); - mocks.verifyTurnstile.mockResolvedValue(false); - mocks.publishCompleteQuickScan.mockResolvedValue({ - scannedAt: 1_800_000_000_000, - token: "score-write-token", - }); - mocks.ensureCanonicalScoreForPublicRun.mockResolvedValue({ - scannedAt: 1_800_000_000_000, - token: "score-write-token", - }); - mocks.hasLegacyReadFallbackProfile.mockResolvedValue(false); - mocks.getLegacyReadFallbackScan.mockResolvedValue(null); - mocks.clearCachedScan.mockResolvedValue(undefined); - mocks.recordAccountLookup.mockResolvedValue(true); - mocks.getLatestPublicScanRun.mockResolvedValue(null); + process.env.GITHUB_ROAST_CLI_API_KEY = "test-key"; mocks.checkRateLimit.mockResolvedValue({ success: true }); mocks.rateLimitHeaders.mockReturnValue({}); - mocks.coalesceScan.mockImplementation(async (_username: string, fn: () => unknown) => fn()); mocks.getCachedScan.mockResolvedValue(null); - mocks.getPublicScanStatus.mockResolvedValue(null); - mocks.requiresDurablePublicScan.mockReturnValue(false); + mocks.coalesceScan.mockImplementation(async (_handle: string, produce: () => unknown) => produce()); + mocks.buildScanResult.mockResolvedValue(quickScan); + mocks.publishCompleteQuickScan.mockResolvedValue(true); + mocks.recordAccountLookup.mockResolvedValue(undefined); + mocks.recordCampaignParticipant.mockResolvedValue(undefined); + mocks.getLegacyReadFallbackScan.mockResolvedValue(null); + mocks.hasLegacyReadFallbackProfile.mockResolvedValue(false); }); afterEach(() => { - if (originalCliKey === undefined) delete process.env.GITHUB_ROAST_CLI_API_KEY; - else process.env.GITHUB_ROAST_CLI_API_KEY = originalCliKey; + delete process.env.GITHUB_ROAST_CLI_API_KEY; vi.clearAllMocks(); }); - it("keeps requiring Turnstile when machine auth is missing", async () => { + it("persists and returns the current v9 quick result without a queue response", async () => { const response = await POST(request()); - expect(response.status).toBe(403); - // Structured error shape: stable machine code plus human-readable fields. - expect(await response.json()).toEqual({ - error: "turnstile_failed", - message: "turnstile failed", - hint: "Complete the browser verification, or call with a Bearer API key.", - }); - expect(mocks.verifyTurnstile).toHaveBeenCalledWith(null, "0.0.0.0"); - expect(mocks.collect).not.toHaveBeenCalled(); - }); - - it("allows the same scan API to be called by CLI with a bearer token", async () => { - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.metrics.username).toBe("DemoDev"); - expect(body.scoring.final_score).toBe(21); - expect(body.cached).toBe(false); - expect(mocks.verifyTurnstile).not.toHaveBeenCalled(); - expect(mocks.collect).toHaveBeenCalledWith("DemoDev"); - expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith( - expect.objectContaining({ metrics: expect.objectContaining({ username: "DemoDev" }) }), - expect.any(Number), - ); - }); - - it("rejects a non-string username with a 400 instead of crashing", async () => { - const response = await POST( - new NextRequest("https://example.test/api/scan", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ username: 12345 }), - }), - ); - - expect(response.status).toBe(400); - expect((await response.json()).error).toBe("invalid_username"); - }); - - it("rate-limits before the cache lookup so cached hits can't bypass the limiter", async () => { - mocks.checkRateLimit.mockResolvedValue({ success: false, limit: 10, remaining: 0, reset: Date.now() + 60_000 }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "60" }); - mocks.getCachedScan.mockResolvedValue({ metrics, scoring }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(429); - expect(response.headers.get("Retry-After")).toBe("60"); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - expect(mocks.recordAccountLookup).not.toHaveBeenCalled(); - }); - - it("fails closed before cache and lookup work when production rate limiting is unavailable", async () => { - mocks.checkRateLimit.mockResolvedValue({ success: false, unavailable: true, retryAfter: 15 }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "15" }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(503); - expect(response.headers.get("Retry-After")).toBe("15"); - await expect(response.json()).resolves.toMatchObject({ error: "rate_limit_unavailable" }); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - expect(mocks.recordAccountLookup).not.toHaveBeenCalled(); - }); - - it("serves a complete persisted run with RateLimit headers once the limiter passes", async () => { - mocks.rateLimitHeaders.mockReturnValue({ "RateLimit-Remaining": "9" }); - mocks.getCachedScan.mockResolvedValue({ metrics, scoring }); - const run = { id: "complete-run" }; - mocks.getPublicScanStatus.mockResolvedValue({ - status: "complete", - run, - scan: { metrics, scoring }, - }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - expect((await response.json()).cached).toBe(true); - expect(response.headers.get("RateLimit-Remaining")).toBe("9"); - expect(mocks.ensureCanonicalScoreForPublicRun).toHaveBeenCalledWith(run); - expect(mocks.collect).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("runs a fresh quick scan before using a stale v3 snapshot", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "DemoDev", collectionVersion: "v3" }, - scan: { metrics, scoring, top_repos: [], recent_prs: [], flood_pr_titles: [] }, - refreshPending: false, - refreshRun: null, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - const response = await POST(request({ auth: "Bearer cli-secret" })); - expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ - metrics: { username: "DemoDev" }, cached: false, - }); - expect(mocks.collect).toHaveBeenCalledWith("DemoDev"); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.ensureCanonicalScoreForPublicRun).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).toHaveBeenCalledTimes(1); - }); - - it("serves a verified v5/v5/v3 scan only when the fresh quick scan fails", async () => { - const legacyScan = { - metrics, - scoring, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - impact_repos: [], - verified_impact_prs: [], - pinned_repos: [], - organizations: [], - }; - mocks.getLegacyReadFallbackScan.mockResolvedValue(legacyScan); - mocks.collect.mockRejectedValueOnce(new Error("GitHub unavailable")); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ + coverage: "quick", metrics: { username: "DemoDev" }, - cached: true, - stale: true, - legacy_read_fallback: true, - refresh_pending: false, - served_score_version: "v5", - served_roast_version: "v5", - served_collection_version: "v3", - target_score_version: "v9", - target_roast_version: "v9", - target_collection_version: "v4", + scoring: { final_score: 71 }, }); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).toHaveBeenCalledWith("DemoDev"); - expect(mocks.collect).toHaveBeenCalledWith("DemoDev"); - expect(mocks.ensureCanonicalScoreForPublicRun).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); + expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan, expect.any(Number)); }); - it("hands a stored v5 profile to the browser only after a quick-scan failure", async () => { - mocks.hasLegacyReadFallbackProfile.mockResolvedValue(true); - mocks.collect.mockRejectedValueOnce(new Error("GitHub unavailable")); + it("serves v5 only after the quick collector fails", async () => { + mocks.buildScanResult.mockRejectedValue(new Error("github unavailable")); + mocks.getLegacyReadFallbackScan.mockResolvedValue(quickScan); - const response = await POST(request({ auth: "Bearer cli-secret" })); + const response = await POST(request()); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ - username: "DemoDev", - cached: true, - stale: true, + coverage: "legacy", legacy_read_fallback: true, - legacy_profile: true, - refresh_pending: false, served_score_version: "v5", served_roast_version: "v5", served_collection_version: "v3", - target_score_version: "v9", - target_roast_version: "v9", - target_collection_version: "v4", - }); - expect(mocks.getLegacyReadFallbackScan).toHaveBeenCalledWith("DemoDev"); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.collect).toHaveBeenCalledWith("DemoDev"); - }); - - it("does not enqueue a durable job merely by serving a v5 fallback", async () => { - mocks.getLegacyReadFallbackScan.mockResolvedValue({ - metrics, - scoring, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - impact_repos: [], - verified_impact_prs: [], - pinned_repos: [], - organizations: [], - }); - mocks.collect.mockRejectedValueOnce(new Error("GitHub unavailable")); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - legacy_read_fallback: true, - refresh_pending: false, - }); - expect(mocks.collect).toHaveBeenCalledWith("DemoDev"); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - }); - - it("uses a stale snapshot only when the current quick scan and v5 fallback are unavailable", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "DemoDev", collectionVersion: "v3" }, - scan: { metrics, scoring, top_repos: [], recent_prs: [], flood_pr_titles: [] }, - refreshPending: false, - refreshRun: { id: "failed-refresh", username: "DemoDev", collectionVersion: "v4" }, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - mocks.collect.mockRejectedValueOnce(new Error("GitHub unavailable")); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - stale: true, - refresh_pending: false, - run_id: "failed-refresh", - }); - expect(mocks.startPublicScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - }); - - it("uses a current-release quick cache without republishing or recollecting", async () => { - mocks.getCachedScan.mockResolvedValue({ metrics, scoring }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - expect((await response.json()).cached).toBe(true); - expect(mocks.clearCachedScan).not.toHaveBeenCalled(); - expect(mocks.collect).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("fails closed when a complete run cannot materialize its canonical score", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "complete", - run: { id: "complete-run" }, - scan: { metrics, scoring }, - }); - mocks.ensureCanonicalScoreForPublicRun.mockResolvedValue(null); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(503); - expect(response.headers.get("Cache-Control")).toBe("no-store"); - expect(mocks.setCachedScan).not.toHaveBeenCalled(); - }); - - it("does not republish a scan returned by the single-flight cache race", async () => { - mocks.coalesceScan.mockResolvedValue({ - metrics, - scoring, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - expect(mocks.collect).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("serves a cached quick result while its durable job is pending", async () => { - mocks.getCachedScan.mockResolvedValue({ metrics, scoring }); - mocks.requiresDurablePublicScan.mockReturnValue(true); - mocks.getPublicScanStatus.mockResolvedValue({ - status: "pending", - run: { id: "active-run" }, - retryAfterSeconds: 5, - }); - mocks.resolvePublicScanFromTrustedQuickScan.mockResolvedValue({ - status: "pending", - run: { id: "active-run" }, - retryAfterSeconds: 5, - headStartJobId: null, - }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - provisional: true, - coverage: "quick", - run_id: "active-run", - }); - expect(mocks.collect).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).toHaveBeenCalledWith( - "DemoDev", - expect.objectContaining({ metrics: expect.objectContaining({ username: "DemoDev" }) }), - expect.anything(), - ); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("starts one response-side worker step after returning a provisional quick result", async () => { - mocks.requiresDurablePublicScan.mockReturnValue(true); - mocks.resolvePublicScanFromTrustedQuickScan.mockResolvedValue({ - status: "pending", - run: { id: "new-run" }, - retryAfterSeconds: 5, - headStartJobId: "new-job-id", }); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - provisional: true, - coverage: "quick", - run_id: "new-run", - }); - expect(mocks.kickPublicScanDrain).toHaveBeenCalledTimes(1); - expect(mocks.kickPublicScanDrain).toHaveBeenCalledWith("new-job-id"); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("returns 503 instead of claiming a fresh quick scan succeeded when persistence returns null", async () => { - mocks.publishCompleteQuickScan.mockResolvedValue(null); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(503); - expect(response.headers.get("Cache-Control")).toBe("no-store"); - expect(response.headers.get("Retry-After")).toBe("5"); - await expect(response.json()).resolves.toMatchObject({ - error: "scan_failed", - message: "score persistence is temporarily unavailable", - }); - expect(mocks.publishCompleteQuickScan).toHaveBeenCalledTimes(1); - }); - - it("returns 503 instead of claiming a fresh quick scan succeeded when persistence throws", async () => { - mocks.publishCompleteQuickScan.mockRejectedValue(new Error("storage unavailable")); - - const response = await POST(request({ auth: "Bearer cli-secret" })); - - expect(response.status).toBe(503); - await expect(response.json()).resolves.toMatchObject({ error: "scan_failed" }); - expect(mocks.publishCompleteQuickScan).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts index bedf5a59..f83460b8 100644 --- a/src/app/api/scan/route.ts +++ b/src/app/api/scan/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { campaignSlug, type CampaignSlug } from "@/lib/campaigns"; import { - ensureCanonicalScoreForPublicRun, hasLegacyReadFallbackProfile, getLegacyReadFallbackScan, publishCompleteQuickScan, @@ -13,19 +12,10 @@ import { coalesceScan, getCachedScan, rateLimitHeaders, - setCachedScan, } from "@/lib/redis"; import { apiError } from "@/lib/api-error"; import { machineAuth } from "@/lib/machine-auth"; import { buildScanResult, scanErrorResponse } from "@/lib/scan-core"; -import { - getPublicScanStatus, - publicScanAdmission, - type PublicScanResolution, - requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan, -} from "@/lib/public-scan"; -import { kickPublicScanDrain } from "@/lib/public-scan-dispatcher"; import { LEGACY_READ_FALLBACK, RUNTIME_RELEASE_VERSIONS } from "@/lib/release-versions"; import { verifyTurnstile } from "@/lib/turnstile"; import { normalizeUsername } from "@/lib/username"; @@ -39,8 +29,6 @@ function clientIp(req: NextRequest): string { return fwd?.split(",")[0]?.trim() || "0.0.0.0"; } -/** Echo the client's Idempotency-Key so retries are correlatable. Scans are - * idempotent per username (shared cache + single-flight), so no storage needed. */ function idempotencyHeaders(req: NextRequest): Record { const key = req.headers.get("idempotency-key"); return key ? { "Idempotency-Key": key } : {}; @@ -61,7 +49,7 @@ function scorePersistenceUnavailable(headers: Record) { class ScorePersistenceError extends Error {} -async function persistFreshQuickScan( +async function persistQuickScan( scan: import("@/lib/types").ScanResult, scannedAt: number, ): Promise { @@ -78,43 +66,15 @@ async function recordSuccessfulLookup( ip: string, campaign: CampaignSlug | null, ): Promise { - // Record the lookup for heat/trending counts, but intentionally DON'T bust the - // leaderboard cache here. Under real traffic this "counted" path fires - // constantly (first lookup per IP per account per 24h), and clearing all 16 - // board variants each time meant the 5-min cache almost never survived — every - // /leaderboard visit then ran the heavy 500-row triple JOIN and hammered Turso - // (slow board + cascading DB timeouts elsewhere). A board that's up to one TTL - // stale is perfectly fine; natural expiry refreshes it. await Promise.all([ recordAccountLookup(username, ip), campaign ? recordCampaignParticipant(campaign, username) : Promise.resolve(), ]); } -async function staleSnapshotResponse(input: { - status: Extract; - headers: Record; -}) { - return NextResponse.json( - { - ...input.status.scan, - cached: true, - coverage: "complete_public", - stale: true, - refresh_pending: input.status.refreshPending, - served_collection_version: input.status.servedCollectionVersion, - target_collection_version: input.status.targetCollectionVersion, - ...(input.status.refreshRun ? { run_id: input.status.refreshRun.id } : {}), - }, - { headers: { ...input.headers, "Cache-Control": "no-store" } }, - ); -} - /** - * An explicit scan may ask for canonical v9/v4 work, but a verified v5/v5/v3 - * artifact is still immediately useful to the visitor. Reading it never starts - * full-history work: only a newly collected quick scan can prove that a full - * collection is required. + * A verified v5/v5/v3 profile is only an emergency read fallback. Successful + * current quick scans always win and immediately refresh the v9 profile. */ async function legacyReadFallbackResponse(input: { scan: import("@/lib/types").ScanResult; @@ -124,10 +84,9 @@ async function legacyReadFallbackResponse(input: { { ...input.scan, cached: true, - coverage: "complete_public", + coverage: "legacy", stale: true, legacy_read_fallback: true, - refresh_pending: false, served_score_version: LEGACY_READ_FALLBACK.score, served_roast_version: LEGACY_READ_FALLBACK.roast, served_collection_version: LEGACY_READ_FALLBACK.collection, @@ -139,12 +98,6 @@ async function legacyReadFallbackResponse(input: { ); } -/** - * A v5/v5 profile can be useful even when the old v3 ScanResult has expired. - * Return a dedicated handoff rather than inventing a partial scan payload; the - * profile page reads the persisted artifact. A later explicit scan collects a - * fresh quick result before deciding whether to enqueue canonical v9/v9/v4. - */ async function legacyReadFallbackProfileResponse(input: { username: string; headers: Record; @@ -156,7 +109,6 @@ async function legacyReadFallbackProfileResponse(input: { stale: true, legacy_read_fallback: true, legacy_profile: true, - refresh_pending: false, served_score_version: LEGACY_READ_FALLBACK.score, served_roast_version: LEGACY_READ_FALLBACK.roast, served_collection_version: LEGACY_READ_FALLBACK.collection, @@ -168,57 +120,25 @@ async function legacyReadFallbackProfileResponse(input: { ); } -/** - * Deliver the trusted bounded result immediately, then let the server worker - * complete the evidence only when its metrics prove the bounded history is - * incomplete. This response is deliberately not a canonical artifact: formal - * v9/v9/v4 storage is written only by a complete quick scan or the durable run. - */ -async function provisionalQuickResponse(input: { - username: string; +function immediateResponse(input: { scan: import("@/lib/types").ScanResult; cached: boolean; headers: Record; - admission: ReturnType; }) { - const resolution = await resolvePublicScanFromTrustedQuickScan( - input.username, - input.scan, - input.admission, - ); - if (resolution.status === "pending" && resolution.headStartJobId) { - kickPublicScanDrain(resolution.headStartJobId); - } - if (resolution.status === "complete") { - const scoreWrite = await ensureCanonicalScoreForPublicRun(resolution.run); - if (!scoreWrite) return scorePersistenceUnavailable(input.headers); - await setCachedScan(resolution.scan.metrics.username, resolution.scan); - return NextResponse.json( - { ...resolution.scan, cached: true, coverage: "complete_public" }, - { headers: input.headers }, - ); - } return NextResponse.json( { ...input.scan, cached: input.cached, + // The bounded collector is the product contract. It never waits for a + // second full-history pass before scoring or roasting the account. coverage: "quick", - provisional: true, - refresh_pending: resolution.status === "pending", - enrichment_status: resolution.status, - ...(resolution.status === "pending" - ? { run_id: resolution.run.id, retry_after: resolution.retryAfterSeconds } - : { retry_after: resolution.retryAfterSeconds }), }, - { headers: { ...input.headers, "Cache-Control": "no-store" } }, + { headers: input.headers }, ); } export async function POST(req: NextRequest) { const idem = idempotencyHeaders(req); - - // Fields stay `unknown`: scripted clients send numbers/objects here, and the - // validators must answer with a 400 rather than crash on a type assumption. let body: { username?: unknown; turnstileToken?: unknown; campaign?: unknown }; try { body = await req.json(); @@ -227,37 +147,23 @@ export async function POST(req: NextRequest) { } const username = normalizeUsername(body.username); - if (!username) { - return apiError("invalid_username", { status: 400, headers: idem }); - } + if (!username) return apiError("invalid_username", { status: 400, headers: idem }); + const campaign = campaignSlug(body.campaign); if (body.campaign !== undefined && !campaign) { return apiError("invalid_body", { status: 400, headers: idem }); } const ip = clientIp(req); - const auth = machineAuth(req); - if (auth === "invalid") { - // An Authorization header was sent but the key is wrong — tell agents how to - // authenticate (spec-shaped WWW-Authenticate is added by apiError on 401). - return apiError("unauthorized", { status: 401, headers: idem }); - } + if (auth === "invalid") return apiError("unauthorized", { status: 401, headers: idem }); if (auth === "absent") { const token = typeof body.turnstileToken === "string" ? body.turnstileToken : null; - const human = await verifyTurnstile(token, ip); - if (!human) { + if (!(await verifyTurnstile(token, ip))) { return apiError("turnstile_failed", { status: 403, headers: idem }); } } - const durableAdmission = publicScanAdmission( - auth === "valid" ? `bearer:${req.headers.get("authorization") ?? ""}` : `ip:${ip}`, - ); - // Rate-limit BEFORE the cache lookup. The cached path used to skip the - // limiter as "cheap", but it still recorded a lookup per request — a bot - // burst replaying cached usernames bypassed the limiter entirely and - // exhausted Turso's connection pool (2026-07 incident). const limit = await checkRateLimit(ip); const rlHeaders = rateLimitHeaders(limit); if (!limit.success) { @@ -267,98 +173,38 @@ export async function POST(req: NextRequest) { }); } - // Check the canonical durable state first. It always wins over the emergency - // read fallback if a v9/v4 result already exists. - const status = await getPublicScanStatus(username); - if (status?.status === "complete") { - const scoreWrite = await ensureCanonicalScoreForPublicRun(status.run); - if (!scoreWrite) return scorePersistenceUnavailable({ ...idem, ...rlHeaders }); - await setCachedScan(status.scan.metrics.username, status.scan); - await recordSuccessfulLookup(status.scan.metrics.username, ip, campaign); - return NextResponse.json( - { ...status.scan, cached: true, coverage: "complete_public" }, - { headers: { ...idem, ...rlHeaders } }, - ); - } - - // A current-release quick cache is a server-authored bounded snapshot. It is - // valid for an immediate provisional roast even while a durable run is - // pending; the worker replaces it atomically with the complete v9/v9/v4 - // snapshot when all evidence arrives. const cached = await getCachedScan(username); if (cached) { - await recordSuccessfulLookup(cached.metrics.username, ip, campaign); - if (requiresDurablePublicScan(cached)) { - return provisionalQuickResponse({ - username: cached.metrics.username, - scan: cached, - cached: true, - headers: { ...idem, ...rlHeaders }, - admission: durableAdmission, - }); + if (!(await persistQuickScan(cached, Date.now()))) { + return scorePersistenceUnavailable({ ...idem, ...rlHeaders }); } - return NextResponse.json( - { ...cached, cached: true, coverage: "complete_public" }, - { headers: { ...idem, ...rlHeaders } }, - ); + await recordSuccessfulLookup(cached.metrics.username, ip, campaign); + return immediateResponse({ scan: cached, cached: true, headers: { ...idem, ...rlHeaders } }); } try { const result = await coalesceScan(username, async () => { - const freshScanStartedAt = Date.now(); - const freshResult = await buildScanResult(username); - if ( - !requiresDurablePublicScan(freshResult) && - !(await persistFreshQuickScan(freshResult, freshScanStartedAt)) - ) { - throw new ScorePersistenceError(); - } - return freshResult; + const scannedAt = Date.now(); + const quickScan = await buildScanResult(username); + if (!(await persistQuickScan(quickScan, scannedAt))) throw new ScorePersistenceError(); + return quickScan; }); await recordSuccessfulLookup(result.metrics.username, ip, campaign); - if (requiresDurablePublicScan(result)) { - return provisionalQuickResponse({ - username: result.metrics.username, - scan: result, - cached: false, - headers: { ...idem, ...rlHeaders }, - admission: durableAdmission, - }); - } - return NextResponse.json( - { ...result, cached: false }, - { headers: { ...idem, ...rlHeaders } }, - ); - } catch (e) { - if (e instanceof ScorePersistenceError) { + return immediateResponse({ scan: result, cached: false, headers: { ...idem, ...rlHeaders } }); + } catch (error) { + if (error instanceof ScorePersistenceError) { return scorePersistenceUnavailable({ ...idem, ...rlHeaders }); } - // A failed fresh quick scan must not hide a verified v5/v5/v3 artifact, - // but serving the fallback alone never schedules canonical work. A future - // successful quick scan will decide whether the account actually needs it. const legacyScan = await getLegacyReadFallbackScan(username); if (legacyScan) { - return legacyReadFallbackResponse({ - scan: legacyScan, - headers: { ...idem, ...rlHeaders }, - }); + return legacyReadFallbackResponse({ scan: legacyScan, headers: { ...idem, ...rlHeaders } }); } if (await hasLegacyReadFallbackProfile(username)) { - return legacyReadFallbackProfileResponse({ - username, - headers: { ...idem, ...rlHeaders }, - }); - } - if (status?.status === "stale") { - await recordSuccessfulLookup(status.scan.metrics.username, ip, campaign); - return staleSnapshotResponse({ - status, - headers: { ...idem, ...rlHeaders }, - }); + return legacyReadFallbackProfileResponse({ username, headers: { ...idem, ...rlHeaders } }); } - const { error, status: responseStatus, retry_after } = scanErrorResponse(e); - return apiError(error as Parameters[0], { - status: responseStatus, + const { error: code, status, retry_after } = scanErrorResponse(error); + return apiError(code as Parameters[0], { + status, headers: { ...idem, ...rlHeaders, diff --git a/src/app/api/score/[username]/route.test.ts b/src/app/api/score/[username]/route.test.ts index 8c106a0e..1705416a 100644 --- a/src/app/api/score/[username]/route.test.ts +++ b/src/app/api/score/[username]/route.test.ts @@ -1,376 +1,65 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SCORE_CACHE_VERSION } from "@/lib/cache-version"; -import { PUBLIC_SCAN_COLLECTION_VERSION } from "@/lib/scan-run-types"; +import type { ScanResult } from "@/lib/types"; const mocks = vi.hoisted(() => ({ - getAccountDetail: vi.fn(), - ensureCanonicalScoreForPublicRun: vi.fn(), - publishCompleteQuickScan: vi.fn(), - recordAccountLookup: vi.fn(), - getPercentileCached: vi.fn(), - getRankCached: vi.fn(), - checkPublicScanStatusRateLimit: vi.fn(), + buildScanResult: vi.fn(), checkRateLimit: vi.fn(), coalesceScan: vi.fn(), + getAccountDetail: vi.fn(), getCachedScan: vi.fn(), - clearCachedScan: vi.fn(), + getPercentileCached: vi.fn(), + getRankCached: vi.fn(), + publishCompleteQuickScan: vi.fn(), rateLimitHeaders: vi.fn(), - setCachedScan: vi.fn(), - buildScanResult: vi.fn(), - scanErrorResponse: vi.fn(), - getPublicScanStatus: vi.fn(), - publicScanAdmission: vi.fn(() => ({ bucket: "test", limit: 2, windowMs: 60_000, maxActiveJobs: 24 })), - requiresDurablePublicScan: vi.fn(), - resolvePublicScanFromTrustedQuickScan: vi.fn(), - kickPublicScanDrain: vi.fn(), + recordAccountLookup: vi.fn(), })); vi.mock("@/lib/db", () => ({ - ensureCanonicalScoreForPublicRun: mocks.ensureCanonicalScoreForPublicRun, getAccountDetail: mocks.getAccountDetail, publishCompleteQuickScan: mocks.publishCompleteQuickScan, recordAccountLookup: mocks.recordAccountLookup, })); - vi.mock("@/lib/rank", () => ({ getPercentileCached: mocks.getPercentileCached, getRankCached: mocks.getRankCached, })); - vi.mock("@/lib/redis", () => ({ - checkPublicScanStatusRateLimit: mocks.checkPublicScanStatusRateLimit, checkRateLimit: mocks.checkRateLimit, coalesceScan: mocks.coalesceScan, - clearCachedScan: mocks.clearCachedScan, getCachedScan: mocks.getCachedScan, rateLimitHeaders: mocks.rateLimitHeaders, - setCachedScan: mocks.setCachedScan, -})); - -vi.mock("@/lib/scan-core", () => ({ - buildScanResult: mocks.buildScanResult, - scanErrorResponse: mocks.scanErrorResponse, -})); - -vi.mock("@/lib/public-scan", () => ({ - getPublicScanStatus: mocks.getPublicScanStatus, - publicScanAdmission: mocks.publicScanAdmission, - requiresDurablePublicScan: mocks.requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan: mocks.resolvePublicScanFromTrustedQuickScan, -})); - -vi.mock("@/lib/public-scan-dispatcher", () => ({ - kickPublicScanDrain: mocks.kickPublicScanDrain, })); +vi.mock("@/lib/scan-core", () => ({ buildScanResult: mocks.buildScanResult })); import { GET } from "./route"; const quickScan = { - metrics: { - username: "DemoDev", - profile_url: "https://github.com/DemoDev", - avatar_url: "https://avatars.githubusercontent.com/u/1", - }, - scoring: { - final_score: 21, - tier: "NPC", - tier_label: "ordinary", - sub_scores: {}, - base_score: 21, - total_penalty: 0, - red_flags: [], - }, -}; - -function request() { - return GET(new NextRequest("https://example.test/api/score/DemoDev"), { - params: Promise.resolve({ username: "DemoDev" }), - }); -} + metrics: { username: "DemoDev", name: "Demo", profile_url: "https://github.com/DemoDev", avatar_url: null }, + scoring: { final_score: 71, tier: "人上人", tier_label: "trusted", sub_scores: {}, base_score: 71, total_penalty: 0, red_flags: [] }, +} as unknown as ScanResult; -describe("score durable scan guardrails", () => { +describe("GET /api/score immediate quick contract", () => { beforeEach(() => { - vi.clearAllMocks(); mocks.getAccountDetail.mockResolvedValue(null); - mocks.checkPublicScanStatusRateLimit.mockResolvedValue({ success: true }); mocks.checkRateLimit.mockResolvedValue({ success: true }); mocks.rateLimitHeaders.mockReturnValue({}); - mocks.getPublicScanStatus.mockResolvedValue(null); mocks.getCachedScan.mockResolvedValue(null); - mocks.clearCachedScan.mockResolvedValue(undefined); - mocks.requiresDurablePublicScan.mockReturnValue(false); - mocks.publishCompleteQuickScan.mockResolvedValue({ - scannedAt: 1_800_000_000_000, - token: "score-write-token", - }); - mocks.ensureCanonicalScoreForPublicRun.mockResolvedValue({ - scannedAt: 1_800_000_000_000, - token: "score-write-token", - }); - mocks.coalesceScan.mockImplementation(async (_username: string, producer: () => unknown) => producer()); + mocks.coalesceScan.mockImplementation(async (_handle: string, produce: () => unknown) => produce()); mocks.buildScanResult.mockResolvedValue(quickScan); + mocks.publishCompleteQuickScan.mockResolvedValue(true); + mocks.recordAccountLookup.mockResolvedValue(undefined); + mocks.getPercentileCached.mockResolvedValue(null); + mocks.getRankCached.mockResolvedValue(null); }); - it("serves a stored stale score without touching GitHub or the durable queue", async () => { - mocks.getAccountDetail.mockResolvedValue({ - username: "stored-fixture", - display_name: "Stored Fixture", - avatar_url: null, - profile_url: "https://profiles.example.invalid/stored-fixture", - final_score: 84, - tier: "人上人", - tags: { zh: [], en: [] }, - roast_line: { zh: "", en: "" }, - sub_scores: {}, - roast: null, - roast_en: null, - score_version: SCORE_CACHE_VERSION, - score_source_collection_version: null, - score_source_snapshot_hash: null, - scanned_at: 1_800_000_000_000, - prev_score: null, - prev_scanned_at: null, + it("materializes a cold account instead of returning 202", async () => { + const response = await GET(new NextRequest("https://example.test/api/score/DemoDev"), { + params: Promise.resolve({ username: "DemoDev" }), }); - mocks.getPercentileCached.mockResolvedValue({ below: 8, total: 10 }); - mocks.getRankCached.mockResolvedValue({ rank: 2, total: 10, below: 8 }); - - const response = await GET( - new NextRequest("https://example.test/api/score/stored-fixture"), - { params: Promise.resolve({ username: "stored-fixture" }) }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toContain("s-maxage=600"); - await expect(response.json()).resolves.toMatchObject({ - source: "indexed", - stale: true, - username: "stored-fixture", - final_score: 84, - profile: "https://ghfind.com/u/stored-fixture", - }); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("keeps an existing durable job passive when the public score is read", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "pending", - run: { id: "active-run", username: "DemoDev" }, - retryAfterSeconds: 5, - headStartJobId: null, - }); - - const response = await request(); - - expect(response.status).toBe(202); - expect(await response.json()).toMatchObject({ run_id: "active-run" }); - expect(mocks.kickPublicScanDrain).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - }); - - it("serves a v3 score as stale without writing v9 or creating a refresh", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "DemoDev", collectionVersion: "v3" }, - scan: quickScan, - refreshPending: false, - refreshRun: null, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - - const response = await request(); expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toBe("no-store"); - await expect(response.json()).resolves.toMatchObject({ - source: "stale_public", - stale: true, - refresh_pending: false, - served_collection_version: "v3", - target_collection_version: "v4", - }); - expect(mocks.ensureCanonicalScoreForPublicRun).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("limits status reads before a durable lookup", async () => { - mocks.checkPublicScanStatusRateLimit.mockResolvedValue({ success: false }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "60" }); - - const response = await request(); - - expect(response.status).toBe(429); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - }); - - it("fails closed before a durable status lookup when request protection is unavailable", async () => { - mocks.checkPublicScanStatusRateLimit.mockResolvedValue({ success: false, unavailable: true, retryAfter: 15 }); - mocks.rateLimitHeaders.mockReturnValue({ "Retry-After": "15" }); - - const response = await request(); - - expect(response.status).toBe(503); - expect(response.headers.get("Retry-After")).toBe("15"); - await expect(response.json()).resolves.toMatchObject({ error: "rate_limit_unavailable" }); - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - }); - - it("starts one response-side step only for a newly created durable job", async () => { - mocks.requiresDurablePublicScan.mockReturnValue(true); - mocks.resolvePublicScanFromTrustedQuickScan.mockResolvedValue({ - status: "pending", - run: { id: "new-run", username: "DemoDev" }, - retryAfterSeconds: 5, - headStartJobId: "new-job-id", - }); - - const response = await request(); - - expect(response.status).toBe(202); - expect(mocks.kickPublicScanDrain).toHaveBeenCalledTimes(1); - expect(mocks.kickPublicScanDrain).toHaveBeenCalledWith("new-job-id"); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("publishes a fresh trusted quick scan before returning its live score", async () => { - const response = await request(); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - source: "live", - cached: false, - username: "DemoDev", - final_score: 21, - }); - expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith( - quickScan, - expect.any(Number), - ); - }); - - it("discards cached quick scans without a persisted run and publishes fresh data", async () => { - mocks.getCachedScan.mockResolvedValue(quickScan); - - const response = await request(); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - source: "live", - cached: false, - }); - expect(mocks.clearCachedScan).toHaveBeenCalledWith("DemoDev"); - expect(mocks.buildScanResult).toHaveBeenCalledWith("DemoDev"); - expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith( - quickScan, - expect.any(Number), - ); - }); - - it("serves a complete persisted run only after its canonical score exists", async () => { - const run = { id: "complete-run" }; - mocks.getPublicScanStatus.mockResolvedValue({ - status: "complete", - run, - scan: quickScan, - }); - - const response = await request(); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - source: "complete_public", - cached: true, - }); - expect(mocks.ensureCanonicalScoreForPublicRun).toHaveBeenCalledWith(run); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - }); - - it("fails closed when a complete run cannot materialize its canonical score", async () => { - mocks.getPublicScanStatus.mockResolvedValue({ - status: "complete", - run: { id: "complete-run" }, - scan: quickScan, - }); - mocks.ensureCanonicalScoreForPublicRun.mockResolvedValue(null); - - const response = await request(); - - expect(response.status).toBe(503); - expect(response.headers.get("Cache-Control")).toBe("no-store"); - expect(mocks.setCachedScan).not.toHaveBeenCalled(); - }); - - it("uses the long cache only for exact v9/v4 score provenance", async () => { - mocks.getAccountDetail.mockResolvedValue({ - username: "canonical-fixture", - display_name: null, - avatar_url: null, - profile_url: null, - final_score: 84, - tier: "人上人", - tags: { zh: [], en: [] }, - roast_line: { zh: "", en: "" }, - sub_scores: {}, - roast: null, - roast_en: null, - score_version: SCORE_CACHE_VERSION, - score_source_collection_version: PUBLIC_SCAN_COLLECTION_VERSION, - score_source_snapshot_hash: "a".repeat(64), - scanned_at: 1_800_000_000_000, - prev_score: null, - prev_scanned_at: null, - }); - - const response = await GET( - new NextRequest("https://example.test/api/score/canonical-fixture"), - { params: Promise.resolve({ username: "canonical-fixture" }) }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toContain("s-maxage=3600"); - await expect(response.json()).resolves.toMatchObject({ stale: false }); - }); - - it("does not republish a quick scan returned by the single-flight cache race", async () => { - mocks.coalesceScan.mockResolvedValue(quickScan); - - const response = await request(); - - expect(response.status).toBe(200); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - expect(mocks.publishCompleteQuickScan).not.toHaveBeenCalled(); - }); - - it("returns 503 instead of publishing a fresh score response when persistence returns null", async () => { - mocks.publishCompleteQuickScan.mockResolvedValue(null); - - const response = await request(); - - expect(response.status).toBe(503); - expect(response.headers.get("Cache-Control")).toBe("no-store"); - expect(response.headers.get("Retry-After")).toBe("5"); - await expect(response.json()).resolves.toMatchObject({ - error: "scan_failed", - message: "score persistence is temporarily unavailable", - }); - }); - - it("returns 503 instead of publishing a fresh score response when persistence throws", async () => { - mocks.publishCompleteQuickScan.mockRejectedValue(new Error("storage unavailable")); - - const response = await request(); - - expect(response.status).toBe(503); - await expect(response.json()).resolves.toMatchObject({ - error: "scan_failed", - }); + await expect(response.json()).resolves.toMatchObject({ source: "quick", coverage: "quick", final_score: 71 }); + expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan, expect.any(Number)); }); }); diff --git a/src/app/api/score/[username]/route.ts b/src/app/api/score/[username]/route.ts index 9f43f62d..c6fe7e91 100644 --- a/src/app/api/score/[username]/route.ts +++ b/src/app/api/score/[username]/route.ts @@ -1,6 +1,5 @@ import { NextRequest } from "next/server"; import { - ensureCanonicalScoreForPublicRun, getAccountDetail, publishCompleteQuickScan, recordAccountLookup, @@ -11,23 +10,12 @@ import { beatPercent } from "@/lib/percentile"; import { TIER_KEY } from "@/lib/tier"; import { SITE_URL } from "@/lib/site"; import { - checkPublicScanStatusRateLimit, checkRateLimit, - clearCachedScan, coalesceScan, getCachedScan, rateLimitHeaders, - setCachedScan, } from "@/lib/redis"; import { buildScanResult, scanErrorResponse } from "@/lib/scan-core"; -import { - getPublicScanStatus, - publicScanAdmission, - type PublicScanResolution, - requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan, -} from "@/lib/public-scan"; -import { kickPublicScanDrain } from "@/lib/public-scan-dispatcher"; import { SCORE_CACHE_VERSION } from "@/lib/cache-version"; import { PUBLIC_SCAN_COLLECTION_VERSION } from "@/lib/scan-run-types"; import type { ScanResult, Tier } from "@/lib/types"; @@ -36,10 +24,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -// Indexed accounts change rarely — cache hard at the edge. const RATED_CACHE = "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400"; -// Freshly (live) scored accounts: shorter, so they flip to the richer indexed -// payload soon after someone generates a roast for them. const LIVE_CACHE = "public, max-age=0, s-maxage=600, stale-while-revalidate=3600"; const MISS_CACHE = "public, max-age=0, s-maxage=60, stale-while-revalidate=300"; @@ -48,12 +33,7 @@ function clientIp(req: NextRequest): string { return fwd?.split(",")[0]?.trim() || "0.0.0.0"; } -function json( - body: unknown, - status: number, - cache: string, - extra?: Record, -): Response { +function json(body: unknown, status: number, cache: string, extra?: Record): Response { return new Response(JSON.stringify(body), { status, headers: { @@ -79,7 +59,7 @@ function scorePersistenceUnavailable(headers: Record): Response class ScorePersistenceError extends Error {} -async function persistFreshQuickScan(scan: ScanResult, scannedAt: number): Promise { +async function persistQuickScan(scan: ScanResult, scannedAt: number): Promise { try { return Boolean(await publishCompleteQuickScan(scan, scannedAt)); } catch { @@ -88,8 +68,6 @@ async function persistFreshQuickScan(scan: ScanResult, scannedAt: number): Promi } } -/** Deterministic global percentile/rank for a score, computed against the scored - * population WITHOUT requiring the account to be in it. */ async function percentileFor(finalScore: number) { const [rank, pct] = await Promise.all([ getRankCached(finalScore), @@ -100,57 +78,38 @@ async function percentileFor(finalScore: number) { : null; } -function durableResponse( - username: string, - resolution: Exclude, - headers: Record, -) { - if (resolution.status === "pending") { - return json( - { - error: "scan_enrichment_pending", - username, - run_id: resolution.run.id, - retry_after: resolution.retryAfterSeconds, - }, - 202, - MISS_CACHE, - { ...headers, "Retry-After": String(resolution.retryAfterSeconds) }, - ); - } - if (resolution.status === "queue_full" || resolution.status === "admission_limited") { - return json( - { error: resolution.status, username, retry_after: resolution.retryAfterSeconds }, - 429, - MISS_CACHE, - { ...headers, "Retry-After": String(resolution.retryAfterSeconds), "Cache-Control": "no-store" }, - ); - } +async function liveScoreResponse(scan: ScanResult, cached: boolean, headers: Record) { + const scoring = scan.scoring; + const metrics = scan.metrics; + const tier = scoring.tier as Tier; return json( { - error: "durable_scan_unavailable", - username, - retry_after: resolution.retryAfterSeconds, + source: "quick", + coverage: "quick", + cached, + username: metrics.username, + display_name: metrics.name, + avatar_url: metrics.avatar_url, + profile_url: metrics.profile_url ?? `https://github.com/${metrics.username}`, + final_score: scoring.final_score, + tier, + tier_key: TIER_KEY[tier], + sub_scores: scoring.sub_scores, + base_score: scoring.base_score, + total_penalty: scoring.total_penalty, + red_flags: scoring.red_flags, + tags: null, + roast_line: null, + percentile: await percentileFor(scoring.final_score), + profile: `${SITE_URL}/u/${metrics.username}`, }, - 503, - MISS_CACHE, - { ...headers, "Retry-After": String(resolution.retryAfterSeconds) }, + 200, + LIVE_CACHE, + headers, ); } -/** - * GET /api/score/{username} — public, read-only, deterministic score. No auth, - * no LLM, no money spent on a model. - * - * 1. Indexed hit: return the stored score (tags/roast_line included). - * 2. Miss: fall through to a LIVE deterministic scan — crawl GitHub + run the - * pure scoring engine (same as POST /api/scan, minus the LLM roast). So an - * account that simply hasn't been scored yet returns a real score instead of - * a 404. Protected by the shared scan cache, per-IP rate limit, and - * single-flight coalescing so it can't be used to hammer the GitHub token. - * - * The only remaining 404 is a GitHub login that genuinely does not exist. - */ +/** Public deterministic score: bounded quick collection only, never queue work. */ export async function GET( req: NextRequest, ctx: { params: Promise<{ username: string }> }, @@ -169,10 +128,9 @@ export async function GET( ); } - // 1) Indexed (already roasted / scored) — richest payload, cheapest path. const detail = await getAccountDetail(handle); if (detail) { - const canonicalScore = + const currentScore = detail.score_version === SCORE_CACHE_VERSION && detail.score_source_collection_version === PUBLIC_SCAN_COLLECTION_VERSION && typeof detail.score_source_snapshot_hash === "string" && @@ -180,7 +138,8 @@ export async function GET( return json( { source: "indexed", - stale: !canonicalScore, + coverage: "quick", + stale: !currentScore, username: detail.username, display_name: detail.display_name, avatar_url: detail.avatar_url, @@ -196,191 +155,50 @@ export async function GET( profile: `${SITE_URL}/u/${detail.username}`, }, 200, - canonicalScore ? RATED_CACHE : LIVE_CACHE, + currentScore ? RATED_CACHE : LIVE_CACHE, ); } - // 2) Not indexed at all → score it live, deterministically (NO LLM). - const statusLimit = await checkPublicScanStatusRateLimit(clientIp(req)); - const statusHeaders = rateLimitHeaders(statusLimit); - if (!statusLimit.success) { + const limit = await checkRateLimit(clientIp(req)); + const headers = rateLimitHeaders(limit); + if (!limit.success) { return json( { - error: statusLimit.unavailable ? "rate_limit_unavailable" : "rate_limited", - message: statusLimit.unavailable ? "request protection temporarily unavailable" : "too many status requests", + error: limit.unavailable ? "rate_limit_unavailable" : "rate_limited", + message: limit.unavailable ? "request protection temporarily unavailable" : "too many requests", hint: "retry after the Retry-After interval", }, - statusLimit.unavailable ? 503 : 429, - "no-store", - statusHeaders, - ); - } - const publicStatus = await getPublicScanStatus(handle); - if (publicStatus?.status === "complete") { - const scoreWrite = await ensureCanonicalScoreForPublicRun(publicStatus.run); - if (!scoreWrite) return scorePersistenceUnavailable(statusHeaders); - await setCachedScan(publicStatus.scan.metrics.username, publicStatus.scan); - const s = publicStatus.scan.scoring; - const m = publicStatus.scan.metrics; - const tier = s.tier as Tier; - return json( - { - source: "complete_public", - cached: true, - username: m.username, - display_name: m.name, - avatar_url: m.avatar_url, - profile_url: m.profile_url ?? `https://github.com/${m.username}`, - final_score: s.final_score, - tier, - tier_key: TIER_KEY[tier], - sub_scores: s.sub_scores, - base_score: s.base_score, - total_penalty: s.total_penalty, - red_flags: s.red_flags, - tags: null, - roast_line: null, - percentile: await percentileFor(s.final_score), - profile: `${SITE_URL}/u/${m.username}`, - }, - 200, - LIVE_CACHE, - ); - } - if (publicStatus?.status === "stale") { - const s = publicStatus.scan.scoring; - const m = publicStatus.scan.metrics; - const tier = s.tier as Tier; - return json( - { - source: "stale_public", - cached: true, - stale: true, - refresh_pending: publicStatus.refreshPending, - served_collection_version: publicStatus.servedCollectionVersion, - target_collection_version: publicStatus.targetCollectionVersion, - username: m.username, - display_name: m.name, - avatar_url: m.avatar_url, - profile_url: m.profile_url ?? `https://github.com/${m.username}`, - final_score: s.final_score, - tier, - tier_key: TIER_KEY[tier], - sub_scores: s.sub_scores, - base_score: s.base_score, - total_penalty: s.total_penalty, - red_flags: s.red_flags, - tags: null, - roast_line: null, - percentile: await percentileFor(s.final_score), - profile: `${SITE_URL}/u/${m.username}`, - }, - 200, + limit.unavailable ? 503 : 429, "no-store", + headers, ); } - if (publicStatus) { - return durableResponse(handle, publicStatus, statusHeaders); - } - let cached = await getCachedScan(handle); - if (cached) { - // A cache entry without a canonical run predates atomic publication. Its - // collection time is unknowable, so discard it and produce a fresh scan. - await clearCachedScan(handle); - cached = null; - } - const durableAdmission = publicScanAdmission(`ip:${clientIp(req)}`); - let rlHeaders: Record = {}; - if (!cached) { - const limit = await checkRateLimit(clientIp(req)); - rlHeaders = rateLimitHeaders(limit); - if (!limit.success) { - return json( - { - error: limit.unavailable ? "rate_limit_unavailable" : "rate_limited", - message: limit.unavailable ? "request protection temporarily unavailable" : "too many requests", - hint: "retry after the Retry-After interval", - }, - limit.unavailable ? 503 : 429, - "no-store", - rlHeaders, - ); - } - } + const cached = await getCachedScan(handle); let result: ScanResult; try { - result = cached ?? (await coalesceScan(handle, async () => { - const freshScanStartedAt = Date.now(); - const freshResult = await buildScanResult(handle); - if ( - !requiresDurablePublicScan(freshResult) && - !(await persistFreshQuickScan(freshResult, freshScanStartedAt)) - ) { - throw new ScorePersistenceError(); - } - return freshResult; - })); - } catch (e) { - if (e instanceof ScorePersistenceError) { - return scorePersistenceUnavailable({ ...statusHeaders, ...rlHeaders }); + if (cached) { + if (!(await persistQuickScan(cached, Date.now()))) throw new ScorePersistenceError(); + result = cached; + } else { + result = await coalesceScan(handle, async () => { + const scannedAt = Date.now(); + const quickScan = await buildScanResult(handle); + if (!(await persistQuickScan(quickScan, scannedAt))) throw new ScorePersistenceError(); + return quickScan; + }); } - const { error, status, retry_after } = scanErrorResponse(e); - // account_not_found stays a 404 — the GitHub user genuinely doesn't exist. + } catch (error) { + if (error instanceof ScorePersistenceError) return scorePersistenceUnavailable(headers); + const { error: code, status, retry_after } = scanErrorResponse(error); return json( - { error, message: error.replace(/_/g, " "), ...(retry_after ? { retry_after } : {}) }, + { error: code, message: code.replace(/_/g, " "), ...(retry_after ? { retry_after } : {}) }, status, MISS_CACHE, - { ...rlHeaders, ...(retry_after ? { "Retry-After": String(retry_after) } : {}) }, + { ...headers, ...(retry_after ? { "Retry-After": String(retry_after) } : {}) }, ); } - if (!cached) { - await recordAccountLookup(result.metrics.username, clientIp(req)); - } - - if (requiresDurablePublicScan(result)) { - const durable = await resolvePublicScanFromTrustedQuickScan( - result.metrics.username, - result, - durableAdmission, - ); - if (durable.status === "complete") { - result = durable.scan; - } else { - if (durable.status === "pending" && durable.headStartJobId) { - kickPublicScanDrain(durable.headStartJobId); - } - return durableResponse(result.metrics.username, durable, { ...statusHeaders, ...rlHeaders }); - } - } - - const s = result.scoring; - const m = result.metrics; - const tier = s.tier as Tier; - return json( - { - source: "live", - cached: Boolean(cached), - username: m.username, - display_name: m.name, - avatar_url: m.avatar_url, - profile_url: m.profile_url ?? `https://github.com/${m.username}`, - final_score: s.final_score, - tier, - tier_key: TIER_KEY[tier], - sub_scores: s.sub_scores, - base_score: s.base_score, - total_penalty: s.total_penalty, - red_flags: s.red_flags, - // Not yet roasted, so no LLM-authored copy. - tags: null, - roast_line: null, - percentile: await percentileFor(s.final_score), - profile: `${SITE_URL}/u/${m.username}`, - }, - 200, - LIVE_CACHE, - rlHeaders, - ); + if (!cached) await recordAccountLookup(result.metrics.username, clientIp(req)); + return liveScoreResponse(result, Boolean(cached), headers); } diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 4baba2c6..9e594d67 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -85,7 +85,7 @@ const handler = createMcpHandler( inputSchema: { username: z.string().describe("GitHub login (case-insensitive)") }, annotations: LIVE_READONLY, }, - (args, extra) => guarded(extra, () => scoreUser(args.username, { durablePrincipal: `mcp:${ipFrom(extra)}` })), + (args, extra) => guarded(extra, () => scoreUser(args.username)), ); server.registerTool( @@ -97,7 +97,7 @@ const handler = createMcpHandler( inputSchema: { username: z.string().describe("GitHub login") }, annotations: LIVE_READONLY, }, - (args, extra) => guarded(extra, () => scanUser(args.username, { durablePrincipal: `mcp:${ipFrom(extra)}` })), + (args, extra) => guarded(extra, () => scanUser(args.username)), ); server.registerTool( @@ -112,7 +112,7 @@ const handler = createMcpHandler( }, annotations: LIVE_READONLY, }, - (args, extra) => guarded(extra, () => compareUsers(args.a, args.b, { durablePrincipal: `mcp:${ipFrom(extra)}` })), + (args, extra) => guarded(extra, () => compareUsers(args.a, args.b)), ); server.registerTool( diff --git a/src/app/openapi.json/route.ts b/src/app/openapi.json/route.ts index f76c2787..b11530ef 100644 --- a/src/app/openapi.json/route.ts +++ b/src/app/openapi.json/route.ts @@ -85,11 +85,6 @@ export function GET() { headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, }, - "202": { - description: "Large public history is collecting; poll /api/scan-status/{username}?run_id={run_id} before using a score as final evidence", - headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, "503": { description: "GitHub or request protection temporarily unavailable", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, }, }, @@ -98,10 +93,10 @@ export function GET() { post: { tags: ["scoring"], operationId: "scan", - summary: "Crawl GitHub and compute the full deterministic scan + score", + summary: "Run a bounded GitHub scan and compute the deterministic score", description: - "Authoritative factual payload: metrics, repo/PR signals, sub_scores, red_flags, and " + - "final_score. Deterministic — no LLM. In production, machine callers send " + + "Bounded factual payload: metrics, repo/PR signals, sub_scores, red_flags, and " + + "final_score. Deterministic — no LLM and no background scan queue. In production, machine callers send " + "`Authorization: Bearer `; browser callers pass a Cloudflare Turnstile token.", security: [{ bearerAuth: [] }, {}], parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }], @@ -143,55 +138,10 @@ export function GET() { headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, }, - "202": { - description: "Durable public-history collection started; poll /api/scan-status/{username}?run_id={run_id}", - headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, "503": { description: "GitHub or request protection temporarily unavailable", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, }, }, }, - "/api/scan-status/{username}": { - get: { - tags: ["scoring"], - operationId: "scanStatus", - summary: "Read durable public-history scan progress without starting work", - parameters: [ - { $ref: "#/components/parameters/Username" }, - { - name: "run_id", - in: "query", - required: true, - description: "Opaque durable run id returned by the initiating scan response", - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Complete immutable public scan snapshot", - content: { - "application/json": { - schema: { - type: "object", - required: ["status", "username", "run_id", "scan"], - properties: { - status: { type: "string", enum: ["complete_public"] }, - username: { type: "string" }, - run_id: { type: "string" }, - scan: { $ref: "#/components/schemas/ScanResult" }, - }, - }, - }, - }, - }, - "202": { description: "Collection remains in progress", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "429": { description: "Status polling rate limited", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "404": { description: "No durable scan has been requested", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "503": { description: "Durable run or request protection unavailable", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, "/api/roast": { post: { tags: ["roast"], @@ -231,7 +181,7 @@ export function GET() { responses: { "200": { description: "Streamed roast report", content: { "text/plain": { schema: { type: "string" } } } }, "400": { description: "Missing scan / no LLM configured", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "409": { description: "Large public history is still collecting; roast is intentionally deferred", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "409": { description: "The supplied scan does not match a persisted current score", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, "429": { description: "Rate limited", headers: { "Retry-After": { $ref: "#/components/headers/Retry-After" } }, diff --git a/src/components/CanonicalProfileUpgrade.tsx b/src/components/CanonicalProfileUpgrade.tsx deleted file mode 100644 index 66b91b26..00000000 --- a/src/components/CanonicalProfileUpgrade.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { - canonicalProfileUpgradePollMs, - isCanonicalProfileUpgradeComplete, -} from "@/lib/canonical-profile-upgrade"; - -// Large public histories can take tens of minutes. The legacy profile is -// already visible, so continue observing its single explicit refresh long -// enough to switch to canonical data without starting another job. -const MAX_WAIT_MS = 45 * 60 * 1_000; - -function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve) => { - if (signal.aborted) { - resolve(); - return; - } - const timeout = window.setTimeout(done, ms); - function done() { - window.clearTimeout(timeout); - signal.removeEventListener("abort", done); - resolve(); - } - signal.addEventListener("abort", done, { once: true }); - }); -} - -function removeRefreshRunId(): void { - const url = new URL(window.location.href); - if (!url.searchParams.has("refresh_run_id")) return; - url.searchParams.delete("refresh_run_id"); - window.history.replaceState(window.history.state, "", url); -} - -/** - * Continues one explicit home-page scan after its v5/v5/v3 handoff. It never - * creates a job: the opaque run id is issued by POST /api/scan and verified by - * the status endpoint. Once v4 is complete, this single user-initiated flow - * asks for its v9 report and refreshes the server-rendered profile. - */ -export function CanonicalProfileUpgrade({ - username, - runId, - locale, -}: { - username: string; - runId: string; - locale: string; -}) { - const router = useRouter(); - - useEffect(() => { - const controller = new AbortController(); - const deadline = Date.now() + MAX_WAIT_MS; - - const upgrade = async () => { - let delayMs = 5_000; - while (!controller.signal.aborted && Date.now() < deadline) { - await sleep(delayMs, controller.signal); - if (controller.signal.aborted) return; - try { - const response = await fetch( - `/api/scan-status/${encodeURIComponent(username)}?run_id=${encodeURIComponent(runId)}`, - { signal: controller.signal, cache: "no-store" }, - ); - const status = await response.json().catch(() => null); - if (isCanonicalProfileUpgradeComplete(status)) { - // This is the same explicit scan that initiated the fallback. It is - // allowed one v9 report attempt; Cron never invokes this endpoint. - const roast = await fetch("/api/roast", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, lang: locale }), - signal: controller.signal, - }); - if (roast.body) { - const reader = roast.body.getReader(); - while (!controller.signal.aborted) { - const { done } = await reader.read(); - if (done) break; - } - } - if (!controller.signal.aborted) { - removeRefreshRunId(); - router.refresh(); - } - return; - } - if (response.status === 404) return; - delayMs = canonicalProfileUpgradePollMs(status?.retry_after); - } catch { - if (controller.signal.aborted) return; - delayMs = 10_000; - } - } - }; - - void upgrade(); - return () => controller.abort(); - }, [locale, router, runId, username]); - - return null; -} diff --git a/src/components/Roaster.tsx b/src/components/Roaster.tsx index 714b7fde..3d602c2c 100644 --- a/src/components/Roaster.tsx +++ b/src/components/Roaster.tsx @@ -39,35 +39,12 @@ interface Display { delta: number; } -interface ScanResponse extends ScanResult { - /** A bounded server-authored scan that is usable now but still collecting - * evidence for the formal v9/v9/v4 artifact. */ - provisional?: boolean; - run_id?: string; -} - interface RoasterProps { campaign?: CampaignSlug; analyticsSource?: "home" | CampaignSlug; inputPlaceholder?: string; } -function abortableDelay(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve) => { - if (signal.aborted) { - resolve(); - return; - } - const done = () => { - clearTimeout(timer); - signal.removeEventListener("abort", done); - resolve(); - }; - const timer = setTimeout(done, ms); - signal.addEventListener("abort", done, { once: true }); - }); -} - export function Roaster({ campaign, analyticsSource = "home", @@ -92,7 +69,6 @@ export function Roaster({ const [scan, setScan] = useState(null); const [report, setReport] = useState(""); const [error, setError] = useState(""); - const [pendingMessage, setPendingMessage] = useState(""); const [byoOpen, setByoOpen] = useState(false); const [byoReason, setByoReason] = useState(); const [percentile, setPercentile] = useState<{ @@ -107,16 +83,6 @@ export function Roaster({ const [metaRoast, setMetaRoast] = useState(null); const reportRef = useRef(null); const lastPrefillRef = useRef(null); - const pollAbortRef = useRef(null); - const upgradeAbortRef = useRef(null); - - useEffect( - () => () => { - pollAbortRef.current?.abort(); - upgradeAbortRef.current?.abort(); - }, - [], - ); // Deep-link prefill (?username=): seed the Omnibox; it renders the value and // the user hits Enter to roast. (Focus/scroll now live inside the Omnibox.) @@ -133,7 +99,6 @@ export function Roaster({ setReport(""); setMetaRoast(null); setThinking(""); - pollAbortRef.current?.abort(); // Apply a decoded RoastMeta (header fast path or in-band M-frame) to the // score card / tags / one-liner. @@ -221,7 +186,6 @@ export function Roaster({ return; } setError(""); - setPendingMessage(""); setScan(null); setReport(""); setPercentile(null); @@ -230,33 +194,10 @@ export function Roaster({ setMetaRoast(null); setThinking(""); setScanning(true); - upgradeAbortRef.current?.abort(); // Funnel top: the user committed to a roast. `source` lets us split the // home scanner from other entry points if they get instrumented later. trackEvent("scan_start", { source: analyticsSource }); - const presentScan = (result: ScanResponse, refreshRunId?: string) => { - if (result.provisional) { - // The bounded server scan already uses the current formula, so let - // the visitor see its score and roast now. It is never stored as a - // profile artifact; the active page quietly upgrades after the full - // scan has atomically materialized formal v9/v9/v4 evidence. - trackEvent("scan_complete", { source: analyticsSource, tier: result.scoring.tier }); - setScan(result); - setDisplay({ - score: result.scoring.final_score, - tier: result.scoring.tier, - tierLabel: result.scoring.tier_label, - delta: 0, - }); - setScanning(false); - void runRoast(result); - if (refreshRunId) void followCanonicalUpgrade(result.metrics.username, refreshRunId); - setTimeout( - () => reportRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), - 100, - ); - return; - } + const presentScan = (result: ScanResult) => { const byoKey = loadByoKey(); const forceProfileHandoff = process.env.NODE_ENV !== "production" && searchParams.get("profile") === "1"; @@ -269,7 +210,6 @@ export function Roaster({ } const profileParams = new URLSearchParams({ roasting: "1" }); if (campaign) profileParams.set("campaign", campaign); - if (refreshRunId) profileParams.set("refresh_run_id", refreshRunId); router.push(`/u/${result.metrics.username}?${profileParams.toString()}`); return; // keep `scanning` true so the skeleton persists until navigation } @@ -287,43 +227,9 @@ export function Roaster({ 100, ); }; - const followCanonicalUpgrade = async (handle: string, runId: string) => { - const controller = new AbortController(); - upgradeAbortRef.current?.abort(); - upgradeAbortRef.current = controller; - let retrySeconds = 15; - try { - while (!controller.signal.aborted) { - await abortableDelay(retrySeconds * 1_000, controller.signal); - if (controller.signal.aborted) return; - try { - const statusRes = await fetch( - `/api/scan-status/${encodeURIComponent(handle)}?run_id=${encodeURIComponent(runId)}`, - { signal: controller.signal }, - ); - const status = await statusRes.json(); - if (statusRes.ok && status?.status === "complete_public" && status.scan) { - setError(""); - setPendingMessage(""); - presentScan(status.scan as ScanResponse); - return; - } - if (status?.status === "failed" || statusRes.status === 404) return; - retrySeconds = Math.max(10, Math.min(30, Number(status?.retry_after) || retrySeconds)); - } catch { - if (controller.signal.aborted) return; - // The provisional result remains visible while a transient - // network failure is retried on this still-open page. - } - } - } finally { - if (upgradeAbortRef.current === controller) upgradeAbortRef.current = null; - } - }; - const presentLegacyProfile = (handle: string, refreshRunId?: string) => { + const presentLegacyProfile = (handle: string) => { const profileParams = new URLSearchParams({ roasting: "1" }); if (campaign) profileParams.set("campaign", campaign); - if (refreshRunId) profileParams.set("refresh_run_id", refreshRunId); router.push(`/u/${handle}?${profileParams.toString()}`); }; try { @@ -333,85 +239,17 @@ export function Roaster({ body: JSON.stringify({ username: uname, turnstileToken: token, campaign }), }); const data = await res.json(); - if (res.status === 202 && data?.error === "scan_enrichment_pending") { - setPendingMessage(t("errScanPending")); - let retrySeconds = Math.max(10, Math.min(30, Number(data.retry_after) || 10)); - const runId = typeof data.run_id === "string" ? data.run_id : ""; - if (!runId) { - setPendingMessage(""); - setError(t("errScanFailed")); - setScanning(false); - return; - } - const controller = new AbortController(); - pollAbortRef.current?.abort(); - pollAbortRef.current = controller; - const poll = async () => { - let completed = false; - let failed = false; - try { - // The durable run can legitimately outlive a browser request by - // many minutes. Keep observing this specific run until it reaches - // a terminal state; the component cleanup aborts on navigation. - while (!controller.signal.aborted) { - await abortableDelay(retrySeconds * 1_000, controller.signal); - if (controller.signal.aborted) return; - try { - const statusRes = await fetch( - `/api/scan-status/${encodeURIComponent(String(data.username ?? uname))}?run_id=${encodeURIComponent(runId)}`, - { signal: controller.signal }, - ); - const status = await statusRes.json(); - if (statusRes.ok && status?.status === "complete_public" && status.scan) { - completed = true; - setError(""); - setPendingMessage(""); - presentScan(status.scan as ScanResponse); - return; - } - if (status?.status === "failed" || statusRes.status === 404) { - failed = true; - break; - } - retrySeconds = Math.max( - 10, - Math.min(30, Number(status?.retry_after) || retrySeconds), - ); - } catch { - if (controller.signal.aborted) return; - // A transient network failure must not abandon a durable run. - } - } - } finally { - if (!controller.signal.aborted && !completed && failed) { - setPendingMessage(""); - setError(t("errScanFailed")); - setScanning(false); - } - if (pollAbortRef.current === controller) pollAbortRef.current = null; - } - }; - void poll(); - return; - } if (!res.ok) { - setPendingMessage(""); setError(tScan.has(data?.error) ? tScan(data.error) : t("errScanFailed")); setScanning(false); return; } if (data?.legacy_profile === true && typeof data.username === "string") { - presentLegacyProfile( - data.username, - typeof data?.run_id === "string" ? data.run_id : undefined, - ); + presentLegacyProfile(data.username); return; } - const result = data as ScanResponse; - const refreshRunId = typeof data?.run_id === "string" ? data.run_id : undefined; - presentScan(result, refreshRunId); + presentScan(data as ScanResult); } catch { - setPendingMessage(""); setError(t("errNetworkScan")); setScanning(false); } @@ -538,7 +376,6 @@ export function Roaster({ /> {error &&

{error}

} - {pendingMessage &&

{pendingMessage}

}
diff --git a/src/components/__tests__/Roaster.test.ts b/src/components/__tests__/Roaster.test.ts index 76c58917..74fd48f3 100644 --- a/src/components/__tests__/Roaster.test.ts +++ b/src/components/__tests__/Roaster.test.ts @@ -3,19 +3,11 @@ import { describe, expect, it } from "vitest"; const source = readFileSync(new URL("../Roaster.tsx", import.meta.url), "utf8"); -describe("Roaster scan progression", () => { - it("keeps observing a pending durable run until completion or a terminal state", () => { - expect(source).toContain('setPendingMessage(t("errScanPending"))'); - expect(source).toContain("while (!controller.signal.aborted)"); - expect(source).toContain('status?.status === "failed" || statusRes.status === 404'); - expect(source).not.toContain("2 * 60 * 1_000"); - }); - - it("streams a provisional quick result on the current page and upgrades only an active page", () => { - expect(source).toContain("if (result.provisional)"); +describe("Roaster immediate quick flow", () => { + it("does not poll durable scan status and starts the roast from the quick result", () => { + expect(source).not.toContain("scan-status"); + expect(source).not.toContain("scan_enrichment_pending"); + expect(source).not.toContain("followCanonicalUpgrade"); expect(source).toContain("void runRoast(result)"); - expect(source).toContain("followCanonicalUpgrade(result.metrics.username, refreshRunId)"); - expect(source).toContain("const upgradeAbortRef = useRef(null)"); - expect(source).toContain('status?.status === "complete_public" && status.scan'); }); }); diff --git a/src/lib/__tests__/canonical-profile-upgrade.test.ts b/src/lib/__tests__/canonical-profile-upgrade.test.ts deleted file mode 100644 index c382dd14..00000000 --- a/src/lib/__tests__/canonical-profile-upgrade.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - canonicalProfileUpgradePollMs, - isCanonicalProfileUpgradeComplete, -} from "../canonical-profile-upgrade"; - -describe("canonical profile upgrade handoff", () => { - it("accepts only a completed durable public scan", () => { - expect(isCanonicalProfileUpgradeComplete({ status: "complete_public" })).toBe(true); - expect(isCanonicalProfileUpgradeComplete({ status: "pending" })).toBe(false); - expect(isCanonicalProfileUpgradeComplete(null)).toBe(false); - }); - - it("keeps polling within the bounded status interval", () => { - expect(canonicalProfileUpgradePollMs(undefined)).toBe(5_000); - expect(canonicalProfileUpgradePollMs(1)).toBe(5_000); - expect(canonicalProfileUpgradePollMs("12")).toBe(12_000); - expect(canonicalProfileUpgradePollMs(90)).toBe(30_000); - }); -}); diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 4f5f2d04..cbb0fe52 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -511,6 +511,25 @@ describe("canonical score materialization", () => { }); }); + it("returns only the quick snapshot that backs the current canonical score", async () => { + const username = "current-quick-snapshot-fixture"; + const scan = syntheticScan(username); + const { snapshotHash } = serializeScan(scan); + + await expect(db.publishCompleteQuickScan(scan, 1_910_000_005_000)).resolves.toBeTruthy(); + await expect(db.getCurrentCanonicalQuickScan(username)).resolves.toMatchObject({ + snapshotHash, + scan: { metrics: { username } }, + }); + + const client = createClient({ url: process.env.TURSO_DATABASE_URL! }); + await client.execute({ + sql: `UPDATE scores SET score_source_snapshot_hash = ? WHERE username = ?`, + args: ["0".repeat(64), username], + }); + await expect(db.getCurrentCanonicalQuickScan(username)).resolves.toBeNull(); + }); + it("reuses the same score identity and run for a repeated quick snapshot", async () => { const username = "quick-idempotency-fixture"; const scannedAt = 1_910_000_010_000; diff --git a/src/lib/__tests__/mcp-tools.test.ts b/src/lib/__tests__/mcp-tools.test.ts deleted file mode 100644 index ff720d22..00000000 --- a/src/lib/__tests__/mcp-tools.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - getAccountDetail: vi.fn(), - searchScoredUsers: vi.fn(), - getPercentileCached: vi.fn(), - getRankCached: vi.fn(), - getLeaderboardCached: vi.fn(), - coalesceScan: vi.fn(), - getCachedScan: vi.fn(), - buildScanResult: vi.fn(), - scanErrorResponse: vi.fn(), - getPublicScanStatus: vi.fn(), - publicScanAdmission: vi.fn(), - requiresDurablePublicScan: vi.fn(), - resolvePublicScanFromTrustedQuickScan: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - getAccountDetail: mocks.getAccountDetail, - searchScoredUsers: mocks.searchScoredUsers, -})); - -vi.mock("@/lib/rank", () => ({ - getPercentileCached: mocks.getPercentileCached, - getRankCached: mocks.getRankCached, -})); - -vi.mock("@/lib/leaderboard", () => ({ - getLeaderboardCached: mocks.getLeaderboardCached, -})); - -vi.mock("@/lib/redis", () => ({ - coalesceScan: mocks.coalesceScan, - getCachedScan: mocks.getCachedScan, -})); - -vi.mock("@/lib/scan-core", () => ({ - buildScanResult: mocks.buildScanResult, - scanErrorResponse: mocks.scanErrorResponse, -})); - -vi.mock("@/lib/public-scan", () => ({ - getPublicScanStatus: mocks.getPublicScanStatus, - publicScanAdmission: mocks.publicScanAdmission, - requiresDurablePublicScan: mocks.requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan: mocks.resolvePublicScanFromTrustedQuickScan, -})); - -import { scoreUser } from "../mcp-tools"; - -describe("MCP stored score guardrail", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getAccountDetail.mockResolvedValue({ - username: "mcp-stored-fixture", - display_name: "MCP Stored Fixture", - final_score: 82, - tier: "人上人", - sub_scores: {}, - score_version: "v8", - scanned_at: 1_800_000_000_000, - }); - mocks.getPercentileCached.mockResolvedValue({ below: 8, total: 10 }); - mocks.getRankCached.mockResolvedValue({ rank: 2, total: 10, below: 8 }); - }); - - it("returns a stale stored score without entering any scan path", async () => { - await expect(scoreUser("mcp-stored-fixture")).resolves.toMatchObject({ - source: "indexed", - stale: true, - username: "mcp-stored-fixture", - final_score: 82, - }); - - expect(mocks.getPublicScanStatus).not.toHaveBeenCalled(); - expect(mocks.getCachedScan).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - }); - - it("returns stale v3 evidence without starting a canonical refresh", async () => { - mocks.getAccountDetail.mockResolvedValue(null); - mocks.getPublicScanStatus.mockResolvedValue({ - status: "stale", - run: { id: "legacy-run", username: "mcp-stored-fixture", collectionVersion: "v3" }, - scan: { - metrics: { username: "mcp-stored-fixture", name: "MCP Stored Fixture" }, - scoring: { final_score: 82, tier: "人上人", sub_scores: {}, red_flags: [] }, - }, - refreshPending: false, - refreshRun: null, - servedCollectionVersion: "v3", - targetCollectionVersion: "v4", - }); - - await expect(scoreUser("mcp-stored-fixture")).resolves.toMatchObject({ - source: "stale_public", - stale: true, - served_collection_version: "v3", - }); - expect(mocks.resolvePublicScanFromTrustedQuickScan).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/__tests__/public-scan-dispatcher.test.ts b/src/lib/__tests__/public-scan-dispatcher.test.ts deleted file mode 100644 index c277c746..00000000 --- a/src/lib/__tests__/public-scan-dispatcher.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - processPublicScanJob: vi.fn(), - recordPublicScanWorkerMetrics: vi.fn(), - recordPublicScanStepMetrics: vi.fn(), -})); - -vi.mock("@/lib/public-scan-worker", () => ({ - processPublicScanJob: mocks.processPublicScanJob, -})); - -vi.mock("@/lib/db", () => ({ - recordPublicScanWorkerMetrics: mocks.recordPublicScanWorkerMetrics, - recordPublicScanStepMetrics: mocks.recordPublicScanStepMetrics, -})); - -import { - drainPublicScanJobs, - drainPublicScanJobsFromWorker, -} from "../public-scan-dispatcher"; - -describe("public scan dispatcher", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.recordPublicScanWorkerMetrics.mockResolvedValue(true); - mocks.recordPublicScanStepMetrics.mockResolvedValue(true); - }); - - it("drains canonical worker work until the durable queue is idle", async () => { - const log = vi.spyOn(console, "info").mockImplementation(() => undefined); - mocks.processPublicScanJob - .mockResolvedValueOnce({ - status: "continued", - jobId: "job-id", - runId: "run-id", - phase: "merged_prs", - }) - .mockResolvedValueOnce({ - status: "complete", - jobId: "job-id", - runId: "run-id", - phase: "publish", - }) - .mockResolvedValueOnce({ status: "idle" }); - - await expect( - drainPublicScanJobs({ - maxSteps: 8, - maxDurationMs: 10_000, - source: "worker", - }), - ).resolves.toEqual({ - processed: 2, - results: [ - { status: "continued", jobId: "job-id", runId: "run-id", phase: "merged_prs" }, - { status: "complete", jobId: "job-id", runId: "run-id", phase: "publish" }, - ], - exhaustedBudget: false, - }); - expect(mocks.processPublicScanJob).toHaveBeenCalledTimes(3); - expect(mocks.processPublicScanJob).toHaveBeenNthCalledWith(1, undefined); - expect(mocks.recordPublicScanStepMetrics).toHaveBeenCalledWith({ - collectionVersion: "v4", - observations: [ - expect.objectContaining({ phase: "merged_prs", outcome: "continued" }), - expect.objectContaining({ phase: "publish", outcome: "complete" }), - ], - }); - log.mockRestore(); - }); - - it("caps one worker drain call at its requested number of steps", async () => { - const log = vi.spyOn(console, "info").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockResolvedValue({ - status: "continued", - jobId: "job-id", - runId: "run-id", - phase: "merged_prs", - }); - - await expect( - drainPublicScanJobs({ - maxSteps: 2, - maxDurationMs: 10_000, - source: "worker", - }), - ).resolves.toMatchObject({ processed: 2, exhaustedBudget: true }); - expect(mocks.processPublicScanJob).toHaveBeenCalledTimes(2); - log.mockRestore(); - }); - - it("runs exactly one bounded step for the explicitly created request job", async () => { - const log = vi.spyOn(console, "info").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockResolvedValue({ - status: "continued", - jobId: "target-job-id", - runId: "target-run-id", - phase: "original_repos", - }); - - await expect( - drainPublicScanJobs({ - maxSteps: 24, - maxDurationMs: 50_000, - source: "request", - targetJobId: "target-job-id", - }), - ).resolves.toMatchObject({ processed: 1, exhaustedBudget: true }); - expect(mocks.processPublicScanJob).toHaveBeenCalledOnce(); - expect(mocks.processPublicScanJob).toHaveBeenCalledWith("target-job-id"); - log.mockRestore(); - }); - - it("stops a global drain after slot contention without counting a processed step", async () => { - const log = vi.spyOn(console, "info").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockResolvedValue({ - status: "slot_busy", - jobId: "blocked-job-id", - runId: "blocked-run-id", - phase: "quick", - }); - - await expect( - drainPublicScanJobs({ - maxSteps: 24, - maxDurationMs: 50_000, - source: "worker", - }), - ).resolves.toMatchObject({ processed: 0, exhaustedBudget: false }); - expect(mocks.processPublicScanJob).toHaveBeenCalledOnce(); - expect(mocks.recordPublicScanStepMetrics).toHaveBeenCalledWith({ - collectionVersion: "v4", - observations: [expect.objectContaining({ phase: "quick", outcome: "slot_busy" })], - }); - log.mockRestore(); - }); - - it("records a successful worker heartbeat even when the queue is empty", async () => { - const log = vi.spyOn(console, "info").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockResolvedValue({ status: "idle" }); - - await expect(drainPublicScanJobsFromWorker({ leaseMs: 300_000 })).resolves.toMatchObject({ processed: 0 }); - expect(mocks.processPublicScanJob).toHaveBeenCalledWith({ - jobId: undefined, - leaseMs: 300_000, - }); - expect(mocks.recordPublicScanWorkerMetrics).toHaveBeenCalledWith( - expect.objectContaining({ processed: 0, failedSteps: 0, success: true }), - ); - log.mockRestore(); - }); - - it("does not persist an explicitly throttled idle heartbeat", async () => { - const log = vi.spyOn(console, "info").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockResolvedValue({ status: "idle" }); - - await expect( - drainPublicScanJobsFromWorker({ leaseMs: 300_000, recordIdleHeartbeat: false }), - ).resolves.toMatchObject({ processed: 0 }); - expect(mocks.recordPublicScanWorkerMetrics).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalledWith("public_scan.worker", expect.any(String)); - log.mockRestore(); - }); - - it("records a failed worker heartbeat without logging the raw exception", async () => { - const rawMarker = "sensitive-upstream-marker"; - const log = vi.spyOn(console, "error").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockRejectedValue(new Error(rawMarker)); - - await expect(drainPublicScanJobsFromWorker({ leaseMs: 300_000 })).rejects.toThrow("public scan worker drain failed"); - expect(mocks.recordPublicScanWorkerMetrics).toHaveBeenCalledWith( - expect.objectContaining({ success: false }), - ); - expect(log.mock.calls.flat().join(" ")).not.toContain(rawMarker); - log.mockRestore(); - }); - - it("fails the worker when its durable heartbeat cannot be persisted", async () => { - const rawMarker = "sensitive-metrics-marker"; - const log = vi.spyOn(console, "error").mockImplementation(() => undefined); - mocks.processPublicScanJob.mockResolvedValue({ status: "idle" }); - mocks.recordPublicScanWorkerMetrics.mockRejectedValue(new Error(rawMarker)); - - await expect(drainPublicScanJobsFromWorker({ leaseMs: 300_000 })).rejects.toThrow("public scan worker drain failed"); - expect(mocks.recordPublicScanWorkerMetrics).toHaveBeenCalledTimes(2); - expect(log.mock.calls.flat().join(" ")).not.toContain(rawMarker); - log.mockRestore(); - }); -}); diff --git a/src/lib/__tests__/public-scan-worker.test.ts b/src/lib/__tests__/public-scan-worker.test.ts deleted file mode 100644 index f9d517b4..00000000 --- a/src/lib/__tests__/public-scan-worker.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { PUBLIC_SCAN_COLLECTION_VERSION } from "../scan-run-types"; -import type { ScanResult } from "../types"; - -const mocks = vi.hoisted(() => ({ - PublicScanStorageError: class PublicScanStorageError extends Error { - constructor(readonly operation: string) { - super("public scan storage unavailable"); - this.name = "PublicScanStorageError"; - } - }, - claimPublicScanJob: vi.fn(), - acquirePublicScanExecutionLease: vi.fn(), - getPublicScanRun: vi.fn(), - savePublicScanQuickResult: vi.fn(), - savePublicScanJobProgress: vi.fn(), - releasePublicScanExecutionLease: vi.fn(), - releasePublicScanJobClaim: vi.fn(), - buildScanResult: vi.fn(), - fetchDurablePullRequestPage: vi.fn(), - failPublicScanJob: vi.fn(), - upsertPublicScanPrFacts: vi.fn(), - upsertPublicScanCommitRepoFacts: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - PublicScanStorageError: mocks.PublicScanStorageError, - acquirePublicScanExecutionLease: mocks.acquirePublicScanExecutionLease, - acquirePublicScanRateWindow: vi.fn(), - claimPublicScanJob: mocks.claimPublicScanJob, - completePublicScanRun: vi.fn(), - failPublicScanJob: mocks.failPublicScanJob, - getNextPublicScanCommitVerificationWork: vi.fn(), - getPublicScanContributionAggregates: vi.fn(), - getPublicScanOwnedRepoFacts: vi.fn(), - getPublicScanPrSummary: vi.fn(), - getPublicScanRun: mocks.getPublicScanRun, - materializePublicScanCommitRepoFacts: vi.fn(), - preparePublicScanCommitVerificationWork: vi.fn(), - savePublicScanJobProgress: mocks.savePublicScanJobProgress, - savePublicScanQuickResult: mocks.savePublicScanQuickResult, - splitPublicScanCommitVerificationWork: vi.fn(), - upsertPublicScanCommitCandidates: vi.fn(), - upsertPublicScanCommitRepoFacts: mocks.upsertPublicScanCommitRepoFacts, - upsertPublicScanOwnedRepoFacts: vi.fn(), - upsertPublicScanPrFacts: mocks.upsertPublicScanPrFacts, - recordPublicScanCommitVerificationPage: vi.fn(), - releasePublicScanExecutionLease: mocks.releasePublicScanExecutionLease, - releasePublicScanJobClaim: mocks.releasePublicScanJobClaim, -})); - -vi.mock("@/lib/github", () => ({ - fetchDurableOwnedRepositoryPage: vi.fn(), - fetchDurablePullRequestPage: mocks.fetchDurablePullRequestPage, - hydrateTopRepoEvidence: vi.fn(), - listPublicDefaultBranchCommits: vi.fn(), - searchPublicCommitCandidates: vi.fn(), - verifyWorkflowLandedPublicScanFacts: vi.fn(), -})); - -vi.mock("@/lib/scan-core", () => ({ - applyPublicContributionAggregate: vi.fn(), - applyPublicOriginalRepoInventory: vi.fn(), - buildScanResult: mocks.buildScanResult, -})); - -vi.mock("@/lib/redis", () => ({ setCachedScan: vi.fn() })); - -import { processPublicScanJob } from "../public-scan-worker"; - -const quickScan = { - metrics: { username: "worker-case" }, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - scoring: {}, -} as unknown as ScanResult; - -describe("public scan worker", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.claimPublicScanJob.mockResolvedValue({ - leaseToken: "lease-token", - job: { - id: "job-id", - runId: "run-id", - username: "worker-case", - phase: "quick", - payload: "{}", - attemptCount: 0, - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }, - }); - mocks.acquirePublicScanExecutionLease.mockResolvedValue(1); - mocks.getPublicScanRun.mockResolvedValue({ - id: "run-id", - sourceStatus: {}, - quickScan: null, - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }); - mocks.buildScanResult.mockResolvedValue(quickScan); - mocks.savePublicScanQuickResult.mockResolvedValue(true); - mocks.savePublicScanJobProgress.mockResolvedValue(true); - mocks.failPublicScanJob.mockResolvedValue(true); - mocks.releasePublicScanJobClaim.mockResolvedValue(true); - mocks.fetchDurablePullRequestPage.mockResolvedValue({ facts: [], hasNextPage: false, endCursor: null }); - mocks.upsertPublicScanPrFacts.mockResolvedValue(true); - mocks.upsertPublicScanCommitRepoFacts.mockResolvedValue(true); - }); - - it("persists the quick probe then queues the bounded next phase", async () => { - await expect(processPublicScanJob("job-id")).resolves.toEqual({ - status: "continued", - jobId: "job-id", - runId: "run-id", - phase: "original_repos", - }); - expect(mocks.savePublicScanQuickResult).toHaveBeenCalledWith( - expect.objectContaining({ quickScan: JSON.stringify(quickScan) }), - ); - expect(mocks.upsertPublicScanCommitRepoFacts).toHaveBeenCalledWith( - expect.objectContaining({ facts: [] }), - ); - expect(mocks.savePublicScanJobProgress).toHaveBeenCalledWith( - expect.objectContaining({ - phase: "original_repos", - payload: "{\"page\":1}", - nextRunAt: undefined, - }), - ); - expect(mocks.releasePublicScanExecutionLease).toHaveBeenCalledWith({ - slot: 1, - jobId: "job-id", - leaseToken: "lease-token", - }); - expect(mocks.claimPublicScanJob).toHaveBeenCalledWith({ - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - jobId: "job-id", - }); - }); - - it("uses the dedicated worker lease without changing request-side defaults", async () => { - await expect(processPublicScanJob({ jobId: "job-id", leaseMs: 300_000 })).resolves.toMatchObject({ - status: "continued", - }); - expect(mocks.claimPublicScanJob).toHaveBeenCalledWith({ - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - jobId: "job-id", - leaseMs: 300_000, - }); - expect(mocks.acquirePublicScanExecutionLease).toHaveBeenCalledWith({ - jobId: "job-id", - leaseToken: "lease-token", - leaseMs: 300_000, - }); - }); - - it("rejects a non-canonical job before acquiring quota or calling GitHub", async () => { - mocks.claimPublicScanJob.mockResolvedValue({ - leaseToken: "lease-token", - job: { - id: "obsolete-job-id", - runId: "obsolete-run-id", - username: "synthetic-worker-case", - phase: "quick", - payload: "{}", - attemptCount: 0, - collectionVersion: "obsolete-collection", - }, - }); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - - await expect(processPublicScanJob("obsolete-job-id")).resolves.toEqual({ - status: "failed", - jobId: "obsolete-job-id", - runId: "obsolete-run-id", - phase: "quick", - retryScheduled: false, - }); - expect(mocks.acquirePublicScanExecutionLease).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - expect(mocks.failPublicScanJob).toHaveBeenCalledWith( - expect.objectContaining({ - jobId: "obsolete-job-id", - error: "worker_non_canonical", - retryAt: undefined, - }), - ); - expect(consoleError.mock.calls.flat().join(" ")).not.toContain("synthetic-worker-case"); - consoleError.mockRestore(); - }); - - it("returns a slot-busy claim to the immediately ready queue", async () => { - mocks.acquirePublicScanExecutionLease.mockResolvedValue(null); - - await expect(processPublicScanJob("job-id")).resolves.toEqual({ - status: "slot_busy", - jobId: "job-id", - runId: "run-id", - phase: "quick", - }); - expect(mocks.releasePublicScanJobClaim).toHaveBeenCalledWith({ - jobId: "job-id", - runId: "run-id", - leaseToken: "lease-token", - }); - expect(mocks.getPublicScanRun).not.toHaveBeenCalled(); - expect(mocks.savePublicScanJobProgress).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - }); - - it("surfaces durable storage failures instead of reporting an idle queue", async () => { - mocks.claimPublicScanJob.mockRejectedValue( - new mocks.PublicScanStorageError("claim_job"), - ); - - await expect(processPublicScanJob("job-id")).rejects.toMatchObject({ - name: "PublicScanStorageError", - operation: "claim_job", - }); - expect(mocks.acquirePublicScanExecutionLease).not.toHaveBeenCalled(); - expect(mocks.failPublicScanJob).not.toHaveBeenCalled(); - expect(mocks.buildScanResult).not.toHaveBeenCalled(); - }); - - it("allows only one synthetic concurrent job to reach GitHub work", async () => { - mocks.claimPublicScanJob.mockImplementation(async ({ jobId }: { jobId?: string }) => ({ - leaseToken: `lease-${jobId}`, - job: { - id: jobId, - runId: `run-${jobId}`, - username: `synthetic-${jobId}`, - phase: "quick", - payload: "{}", - attemptCount: 0, - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }, - })); - mocks.acquirePublicScanExecutionLease - .mockResolvedValueOnce(1) - .mockResolvedValueOnce(null); - - const results = await Promise.all([ - processPublicScanJob("job-a"), - processPublicScanJob("job-b"), - ]); - - expect(results.map((result) => result.status).sort()).toEqual(["continued", "slot_busy"]); - expect(mocks.buildScanResult).toHaveBeenCalledOnce(); - expect(mocks.releasePublicScanJobClaim).toHaveBeenCalledOnce(); - expect(mocks.releasePublicScanJobClaim).toHaveBeenCalledWith({ - jobId: "job-b", - runId: "run-job-b", - leaseToken: "lease-job-b", - }); - }); - - it("persists successful contribution-graph commits before collecting complete PR history", async () => { - mocks.buildScanResult.mockResolvedValue({ - ...quickScan, - impact_repos: [{ repo: "public/project", stars: 40_000, commits: 7, prs: 0 }], - }); - - await expect(processPublicScanJob("job-id")).resolves.toMatchObject({ - status: "continued", - phase: "original_repos", - }); - - expect(mocks.upsertPublicScanCommitRepoFacts).toHaveBeenCalledWith( - expect.objectContaining({ - facts: [ - expect.objectContaining({ - repoKey: "public/project", - commits: 7, - source: "contribution_graph", - }), - ], - }), - ); - }); - - it("does not consume commit-search quota when the normal graph aggregate is available", async () => { - mocks.claimPublicScanJob.mockResolvedValue({ - leaseToken: "lease-token", - job: { - id: "job-id", - runId: "run-id", - username: "worker-case", - phase: "workflow_landings", - payload: "{}", - attemptCount: 0, - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }, - }); - mocks.getPublicScanRun.mockResolvedValue({ - id: "run-id", - sourceStatus: { - quick: "complete", - original_repos: "complete", - native_prs: "complete", - workflow_landings: "pending", - commit_recovery: "pending", - }, - quickScan: JSON.stringify({ - ...quickScan, - metrics: { username: "worker-case", commit_contribution_aggregation_unavailable: false }, - }), - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }); - - await expect(processPublicScanJob("job-id")).resolves.toEqual({ - status: "continued", - jobId: "job-id", - runId: "run-id", - phase: "publish", - }); - expect(mocks.savePublicScanJobProgress).toHaveBeenCalledWith( - expect.objectContaining({ - phase: "publish", - sourceStatus: expect.objectContaining({ - workflow_landings: "complete", - commit_recovery: "complete", - }), - }), - ); - }); -}); diff --git a/src/lib/__tests__/public-scan.test.ts b/src/lib/__tests__/public-scan.test.ts deleted file mode 100644 index 91fcf283..00000000 --- a/src/lib/__tests__/public-scan.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { createHash } from "node:crypto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ScanResult } from "../types"; -import { PUBLIC_SCAN_COLLECTION_VERSION, type PublicScanRun } from "../scan-run-types"; - -const mocks = vi.hoisted(() => ({ - enqueuePublicScan: vi.fn(), - getCompletePublicScanRuns: vi.fn(), - getLatestPublicScanRun: vi.fn(), - seedPublicScanQuickResult: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - enqueuePublicScan: mocks.enqueuePublicScan, - getCompletePublicScanRuns: mocks.getCompletePublicScanRuns, - getLatestPublicScanRun: mocks.getLatestPublicScanRun, - seedPublicScanQuickResult: mocks.seedPublicScanQuickResult, -})); - -import { - getPublicScanStatus, - publicScanAdmission, - requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan, - startPublicScan, -} from "../public-scan"; - -function scan(overrides: Partial = {}): ScanResult { - return { - metrics: { - username: "durable-case", - account_age_years: 1, - followers: 0, - following: 0, - public_repos: 1, - merged_pr_count: 1, - total_pr_count: 1, - fetched_repo_count: 1, - original_repo_count: 1, - nonempty_original_repo_count: 1, - fork_repo_count: 0, - empty_original_repo_count: 0, - total_stars: 0, - max_stars: 0, - issues_created: 0, - last_year_contributions: 0, - activity_type_count: 0, - contribution_years_active: 1, - recent_merged_pr_sample: 1, - recent_trivial_pr_count: 0, - external_trivial_pr_count: 0, - max_impact_repo_stars: 0, - impact_pr_count: 0, - impact_depth_raw: 0, - closed_unmerged_pr_count: 0, - pr_rejection_rate: 0, - recent_pr_sample: 1, - top_repo_pr_share: 0, - templated_pr_ratio: 0, - ...overrides, - }, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - scoring: { - final_score: 61, - base_score: 61, - total_penalty: 0, - tier: "NPC", - tier_label: "fixture", - sub_scores: { - account_maturity: 0, - original_project_quality: 0, - contribution_quality: 0, - ecosystem_impact: 0, - community_influence: 0, - activity_authenticity: 0, - }, - red_flags: [], - }, - } as ScanResult; -} - -function pendingRun(): PublicScanRun { - return { - id: "run-id", - username: "durable-case", - scoreVersion: "v7", - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - state: "queued", - coverage: "partial_public", - sourceStatus: {}, - quickScan: null, - snapshot: null, - snapshotHash: null, - startedAt: Date.now(), - completedAt: null, - updatedAt: Date.now(), - lastError: null, - }; -} - -function completedSources() { - return { - quick: "complete", - original_repos: "complete", - native_prs: "complete", - workflow_landings: "complete", - commit_recovery: "complete", - } as const; -} - -function completeRun(collectionVersion = "v3", overrides: Partial = {}): PublicScanRun { - const snapshot = JSON.stringify(scan({ merged_pr_count: 301 })); - return { - ...pendingRun(), - collectionVersion, - state: "complete_public", - coverage: "complete_public", - sourceStatus: completedSources(), - snapshot, - snapshotHash: createHash("sha256").update(snapshot).digest("hex"), - completedAt: Date.now(), - ...overrides, - }; -} - -describe("durable public scan admission", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getLatestPublicScanRun.mockResolvedValue(null); - mocks.getCompletePublicScanRuns.mockResolvedValue([]); - mocks.seedPublicScanQuickResult.mockResolvedValue(true); - }); - - it("namespaces admission buckets by the canonical collection version", () => { - const admission = publicScanAdmission("synthetic-principal"); - expect(admission.bucket).toMatch( - new RegExp(`^durable-admission:${PUBLIC_SCAN_COLLECTION_VERSION}:[a-f0-9]{64}$`), - ); - expect(admission.bucket).not.toContain("synthetic-principal"); - }); - - it("only diverts bounded-coverage cases", () => { - expect(requiresDurablePublicScan(scan())).toBe(false); - expect(requiresDurablePublicScan(scan({ merged_pr_count: 51, recent_merged_pr_sample: 50 }))).toBe(true); - expect(requiresDurablePublicScan(scan({ merged_pr_count: 301 }))).toBe(true); - expect(requiresDurablePublicScan(scan({ total_pr_count: 601 }))).toBe(true); - expect(requiresDurablePublicScan(scan({ public_repos: 5, fetched_repo_count: 2 }))).toBe(true); - expect(requiresDurablePublicScan(scan({ commit_contribution_aggregation_unavailable: true }))).toBe(true); - expect(requiresDurablePublicScan(scan({ merged_pr_contribution_aggregation_incomplete: true }))).toBe(true); - }); - - it("seeds the already-paid quick probe before queueing a new run", async () => { - const run = pendingRun(); - mocks.enqueuePublicScan.mockResolvedValue({ - run, - job: { id: "job-id" }, - created: true, - }); - const quick = scan({ merged_pr_count: 301 }); - - await expect(resolvePublicScanFromTrustedQuickScan("durable-case", quick)).resolves.toMatchObject({ - status: "pending", - run: { id: "run-id" }, - headStartJobId: "job-id", - }); - expect(mocks.seedPublicScanQuickResult).toHaveBeenCalledWith( - expect.objectContaining({ jobId: "job-id", runId: "run-id" }), - ); - }); - - it("returns only an immutable complete snapshot as complete evidence", async () => { - const complete = scan({ merged_pr_count: 301 }); - const snapshot = JSON.stringify(complete); - mocks.getLatestPublicScanRun.mockResolvedValue({ - ...pendingRun(), - state: "complete_public", - coverage: "complete_public", - sourceStatus: completedSources(), - snapshot, - snapshotHash: createHash("sha256").update(snapshot).digest("hex"), - }); - - await expect(startPublicScan("durable-case")).resolves.toMatchObject({ - status: "complete", - scan: { metrics: { username: "durable-case" } }, - }); - expect(mocks.enqueuePublicScan).not.toHaveBeenCalled(); - }); - - it("serves only a validated v3 snapshot while a v4 refresh is pending", async () => { - const refresh = pendingRun(); - const legacy = completeRun(); - mocks.getLatestPublicScanRun.mockResolvedValue(refresh); - mocks.getCompletePublicScanRuns.mockImplementation( - async (_username: string, collectionVersion: string) => - collectionVersion === "v3" ? [legacy] : [], - ); - - await expect(getPublicScanStatus("durable-case")).resolves.toMatchObject({ - status: "stale", - run: { collectionVersion: "v3" }, - refreshRun: { id: "run-id", collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION }, - refreshPending: true, - servedCollectionVersion: "v3", - targetCollectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }); - expect(mocks.enqueuePublicScan).not.toHaveBeenCalled(); - expect(mocks.getCompletePublicScanRuns).toHaveBeenCalledWith("durable-case", "v3"); - }); - - it("rejects corrupt and non-v3 complete rows as stale fallbacks", async () => { - const corrupt = completeRun("v3", { snapshotHash: "not-a-hash" }); - const incompleteScoreSnapshot = JSON.stringify({ - ...scan(), - scoring: { ...scan().scoring, sub_scores: {} }, - }); - const incompleteScore = completeRun("v3", { - snapshot: incompleteScoreSnapshot, - snapshotHash: createHash("sha256").update(incompleteScoreSnapshot).digest("hex"), - }); - const nonCanonical = completeRun("v5"); - mocks.getCompletePublicScanRuns.mockImplementation( - async (_username: string, collectionVersion: string) => - collectionVersion === "v3" ? [corrupt, incompleteScore, nonCanonical] : [], - ); - - await expect(getPublicScanStatus("durable-case")).resolves.toBeNull(); - expect(mocks.getCompletePublicScanRuns).not.toHaveBeenCalledWith("durable-case", "v5"); - expect(mocks.enqueuePublicScan).not.toHaveBeenCalled(); - }); - - it("recollects legacy or corrupt complete snapshots instead of trusting them", async () => { - const complete = scan({ merged_pr_count: 301 }); - const stale = { - ...pendingRun(), - state: "complete_public" as const, - coverage: "complete_public" as const, - sourceStatus: { ...completedSources(), native_prs: "pending" }, - snapshot: JSON.stringify(complete), - snapshotHash: "wrong-hash", - }; - mocks.getLatestPublicScanRun.mockResolvedValue(stale); - mocks.enqueuePublicScan.mockResolvedValue({ - run: pendingRun(), - job: { id: "repair-job" }, - created: true, - }); - - await expect( - resolvePublicScanFromTrustedQuickScan("durable-case", scan({ merged_pr_count: 301 })), - ).resolves.toMatchObject({ - status: "pending", - run: { id: "run-id" }, - }); - expect(mocks.enqueuePublicScan).toHaveBeenCalledTimes(1); - }); - - it("recollects a complete-looking snapshot missing required score facts", async () => { - const snapshot = JSON.stringify({ - metrics: { username: "durable-case" }, - top_repos: [], - recent_prs: [], - flood_pr_titles: [], - scoring: {}, - }); - mocks.getLatestPublicScanRun.mockResolvedValue({ - ...pendingRun(), - state: "complete_public", - coverage: "complete_public", - sourceStatus: completedSources(), - snapshot, - snapshotHash: createHash("sha256").update(snapshot).digest("hex"), - }); - mocks.enqueuePublicScan.mockResolvedValue({ - run: pendingRun(), - job: { id: "repair-job" }, - created: true, - }); - - await expect(startPublicScan("durable-case")).resolves.toMatchObject({ status: "pending" }); - expect(mocks.enqueuePublicScan).toHaveBeenCalledTimes(1); - }); - - it("does not seed a durable run when a route only has untrusted request data", async () => { - mocks.enqueuePublicScan.mockResolvedValue({ - run: pendingRun(), - job: { id: "job-id" }, - created: true, - }); - - await expect(startPublicScan("durable-case")).resolves.toMatchObject({ status: "pending" }); - expect(mocks.seedPublicScanQuickResult).not.toHaveBeenCalled(); - }); - - it("marks an existing durable job as passive so status readers cannot advance it", async () => { - const run = pendingRun(); - mocks.enqueuePublicScan.mockResolvedValue({ - run, - job: { id: "job-id" }, - created: false, - }); - - await expect(startPublicScan("durable-case")).resolves.toMatchObject({ - status: "pending", - headStartJobId: null, - }); - }); -}); diff --git a/src/lib/__tests__/score-materialization.test.ts b/src/lib/__tests__/score-materialization.test.ts index 7c96b844..0b409eb6 100644 --- a/src/lib/__tests__/score-materialization.test.ts +++ b/src/lib/__tests__/score-materialization.test.ts @@ -149,15 +149,10 @@ describe("materializeCanonicalScore", () => { expect(result?.scoreEntry.username).toBe("synthetic-user"); }); - it("accepts a durable-required snapshot only in durable mode with complete sources", () => { + it("accepts bounded quick snapshots even when history is sampled", () => { const durable = scan({ merged_pr_count: 12, recent_merged_pr_sample: 8 }); - expect(materializeCanonicalScore(input(durable))).toBeNull(); - expect( - materializeCanonicalScore( - input(durable, { mode: "durable", sourceStatus: COMPLETE_SOURCES }), - ), - ).not.toBeNull(); + expect(materializeCanonicalScore(input(durable))).not.toBeNull(); }); it.each([ @@ -168,16 +163,16 @@ describe("materializeCanonicalScore", () => { { commit_contribution_aggregation_unavailable: true }, { merged_pr_contribution_aggregation_incomplete: true }, ] satisfies Partial[])( - "rejects an incomplete quick scan %#", + "materializes a bounded quick scan %#", (overrides) => { - expect(materializeCanonicalScore(input(scan(overrides)))).toBeNull(); + expect(materializeCanonicalScore(input(scan(overrides)))).not.toBeNull(); }, ); - it("requires every durable source to be complete", () => { + it("accepts a durable-mode snapshot without requiring a worker source map", () => { const durable = scan({ merged_pr_count: 12, recent_merged_pr_sample: 8 }); - expect(materializeCanonicalScore(input(durable, { mode: "durable" }))).toBeNull(); + expect(materializeCanonicalScore(input(durable, { mode: "durable" }))).not.toBeNull(); expect( materializeCanonicalScore( input(durable, { @@ -185,17 +180,17 @@ describe("materializeCanonicalScore", () => { sourceStatus: { ...COMPLETE_SOURCES, native_prs: "pending" }, }), ), - ).toBeNull(); + ).not.toBeNull(); }); - it("rejects an explicitly partial source set in quick mode", () => { + it("accepts an explicitly partial source set in quick mode", () => { expect( materializeCanonicalScore( input(scan(), { sourceStatus: { ...COMPLETE_SOURCES, original_repos: "unavailable" }, }), ), - ).toBeNull(); + ).not.toBeNull(); }); it.each([ diff --git a/src/lib/agent-docs.ts b/src/lib/agent-docs.ts index 180a30a9..5c7ca1b9 100644 --- a/src/lib/agent-docs.ts +++ b/src/lib/agent-docs.ts @@ -53,7 +53,7 @@ export const USE_CASES = [ export const WHEN_TO_USE = [ `Use GET ${SITE_URL}/api/score/{username} (or the MCP tool score_user) when you need one account's factual score/tier — deterministic, no auth, no LLM. It scores unseen accounts live on demand.`, - `Use POST ${SITE_URL}/api/scan (or scan_user) when you need the full evidence payload: raw metrics, top repos, recent PRs, red flags, sub-scores. High-volume public histories can return 202; poll scan-status with the returned run_id before treating them as complete evidence.`, + `Use POST ${SITE_URL}/api/scan (or scan_user) when you need the bounded evidence payload: raw metrics, top repos, recent PRs, red flags, and sub-scores. It scores and persists the account synchronously; no background scan queue is required.`, `Use POST ${SITE_URL}/api/roast (or the CLI) only when you want the human-facing prose roast — this is the one LLM path and it can spend model credit.`, `Use the leaderboard / developers / stats endpoints for discovery and platform context, NOT as fresh per-user scoring evidence (they are ranked snapshots).`, "Do NOT treat a low score as a factual claim about a person — scores use public signals only; private-org work is invisible to them.", @@ -77,16 +77,15 @@ export function apiSummaryMd(): string { Machine-readable spec: [${SITE_URL}/openapi.json](${SITE_URL}/openapi.json) · API catalog: [${SITE_URL}/.well-known/api-catalog](${SITE_URL}/.well-known/api-catalog) · Auth: [${SITE_URL}/auth.md](${SITE_URL}/auth.md) -- \`GET ${SITE_URL}/api/score/{username}\` — deterministic score, no auth, no LLM; scores unseen accounts live. A \`202 scan_enrichment_pending\` means public history is still being collected; it is not a final score. -- \`POST ${SITE_URL}/api/scan\` { "username": "..." } — full deterministic scan payload (metrics + repo/PR signals + red flags). For large public histories it returns \`202 { error: "scan_enrichment_pending", run_id, retry_after }\` instead of a partial final payload. -- \`GET ${SITE_URL}/api/scan-status/{username}?run_id={run_id}\` — poll a previously requested durable scan using the opaque \`run_id\` returned by the initiating request. Only \`200 { status: "complete_public", scan }\` is complete factual evidence; \`202\` is still collecting and \`503\` is a failed/unavailable durable run. -- \`POST ${SITE_URL}/api/roast\` — LLM roast report (streaming); pass \`byoKey\` for your own model. It returns \`409 scan_enrichment_pending\` rather than roasting a partial large-history scan. +- \`GET ${SITE_URL}/api/score/{username}\` — deterministic score, no auth, no LLM; scores unseen accounts with the bounded quick collector and persists the v9 result. +- \`POST ${SITE_URL}/api/scan\` { "username": "..." } — bounded deterministic scan payload (metrics + repo/PR signals + red flags) and a persisted v9 score. It does not create or wait for a background scan job. +- \`POST ${SITE_URL}/api/roast\` — LLM roast report (streaming); pass \`byoKey\` for your own model. The default path uses the exact persisted quick snapshot and does not wait for historical collection. - \`POST ${SITE_URL}/api/vs-verdict\` { "a": "...", "b": "..." } — head-to-head verdict. - \`GET ${SITE_URL}/api/leaderboard?view=trending|score|heat|progress&window=all|24h|7d|30d&limit={1-500}&offset={n}\` — paginated; walk pages via \`nextOffset\`. - \`GET ${SITE_URL}/api/developers?type=language|org|repo&value={facet}&limit={1-500}&offset={n}\` - \`GET ${SITE_URL}/api/search-users?q={prefix}\` · \`GET ${SITE_URL}/api/stats\` -Errors are JSON: \`{ "error": "", "message": "...", "hint": "..." }\`. Responses carry \`RateLimit-*\` headers; a 429 carries \`Retry-After\`. A durable-history 202/409 also carries \`Retry-After\`; wait or poll instead of retrying the expensive POST loop. Write calls accept an \`Idempotency-Key\` header (scans are idempotent per username). +Errors are JSON: \`{ "error": "", "message": "...", "hint": "..." }\`. Responses carry \`RateLimit-*\` headers; a 429 carries \`Retry-After\`. Write calls accept an \`Idempotency-Key\` header (scans are idempotent per username). Bulk vetting (recruiting screens, candidate pipelines, account-trust checks at scale): the API is free for moderate use; if you need higher rate limits for batch scoring, email [lbm21@tsinghua.org.cn](mailto:lbm21@tsinghua.org.cn) or ask via [${SITE_URL}/contact](${SITE_URL}/contact) — this is a supported use case, not something to work around.`; } diff --git a/src/lib/canonical-profile-upgrade.ts b/src/lib/canonical-profile-upgrade.ts deleted file mode 100644 index d689f965..00000000 --- a/src/lib/canonical-profile-upgrade.ts +++ /dev/null @@ -1,19 +0,0 @@ -const MIN_POLL_MS = 5_000; -const MAX_POLL_MS = 30_000; - -/** The bounded profile-side watcher accepts only the status API's terminal shape. */ -export function isCanonicalProfileUpgradeComplete(value: unknown): boolean { - return ( - typeof value === "object" && - value !== null && - "status" in value && - value.status === "complete_public" - ); -} - -/** Keep status polling inside the endpoint's advertised retry window. */ -export function canonicalProfileUpgradePollMs(retryAfter: unknown): number { - const seconds = typeof retryAfter === "number" ? retryAfter : Number(retryAfter); - if (!Number.isFinite(seconds)) return MIN_POLL_MS; - return Math.max(MIN_POLL_MS, Math.min(MAX_POLL_MS, Math.round(seconds * 1_000))); -} diff --git a/src/lib/db.ts b/src/lib/db.ts index 94c03527..530e8b1b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,10 +2,10 @@ * Turso (libSQL) persistence for the leaderboard + percentile. * * Optional, like {@link ./redis}: most passive persistence functions no-op when - * `TURSO_DATABASE_URL` is unset so the app can run without it. Durable public-scan - * worker operations are the exception: they fail closed so Cron health cannot - * report an unavailable queue as empty. Stores one latest row per scanned account - * plus append-only score snapshots for long-term progress. + * `TURSO_DATABASE_URL` is unset so the app can run without it. Stores one latest + * row per synchronously scanned account plus append-only score snapshots for + * long-term progress. Historical queue records are retained for data safety but + * are not part of the runtime request path. * The score itself is still computed deterministically by `lib/score.ts`; this * layer only persists the result for cross-account ranking. */ @@ -1413,9 +1413,8 @@ function mapPublicScanJob(row: Record): PublicScanJob { } /** - * Atomically publish a complete bounded quick scan and its deterministic score. - * Large/partial scans are rejected by the materializer and must use the durable - * worker path instead. + * Atomically publish a bounded quick scan and its deterministic score. + * The quick snapshot is the production score contract for every account. */ export async function publishCompleteQuickScan( scan: ScanResult, @@ -1991,6 +1990,71 @@ export async function getLatestPublicScanRun( } } +/** + * The exact quick snapshot backing the current canonical score. The roast path + * reads this instead of reconstructing a hash from a client handoff, whose + * response-only fields and prompt truncation would otherwise change the JSON + * bytes. Historical queue rows are ignored unless their fingerprint is still + * the score row currently served to users. + */ +export async function getCurrentCanonicalQuickScan( + username: string, +): Promise<{ scan: ScanResult; snapshotHash: string } | null> { + const db = getClient(); + if (!db) return null; + try { + await ensureSchema(db); + const result = await db.execute({ + sql: `SELECT r.snapshot, r.snapshot_hash + FROM public_scan_runs r + INNER JOIN scores s + ON s.username = r.username + AND s.hidden = 0 + AND s.score_version = ? + AND s.score_source_collection_version = ? + AND s.score_source_snapshot_hash = r.snapshot_hash + WHERE r.username = ? + AND r.score_version = ? + AND r.collection_version = ? + AND r.state = 'complete_public' + AND r.snapshot IS NOT NULL + AND r.snapshot_hash IS NOT NULL + ORDER BY s.scanned_at DESC, r.completed_at DESC, r.id DESC + LIMIT 1`, + args: [ + SCORE_CACHE_VERSION, + PUBLIC_SCAN_COLLECTION_VERSION, + username.toLowerCase(), + SCORE_CACHE_VERSION, + PUBLIC_SCAN_COLLECTION_VERSION, + ], + }); + const row = result.rows[0] as Record | undefined; + const snapshot = typeof row?.snapshot === "string" ? row.snapshot : null; + const snapshotHash = typeof row?.snapshot_hash === "string" ? row.snapshot_hash : null; + if ( + !snapshot || + !snapshotHash || + !/^[a-f0-9]{64}$/.test(snapshotHash) || + createHash("sha256").update(snapshot).digest("hex") !== snapshotHash + ) { + return null; + } + const scan = JSON.parse(snapshot) as Partial; + if ( + typeof scan.metrics?.username !== "string" || + scan.metrics.username.toLowerCase() !== username.toLowerCase() || + !scan.scoring + ) { + return null; + } + return { scan: scan as ScanResult, snapshotHash }; + } catch (error) { + console.error("getCurrentCanonicalQuickScan failed:", error); + return null; + } +} + /** * Return complete snapshots in newest-first order for one exact collection * contract. Callers must validate hash and payload shape before serving a row; @@ -5815,8 +5879,8 @@ export async function getAccountDetail(username: string): Promise { const db = getClient(); @@ -5841,8 +5905,8 @@ export async function hasLegacyReadFallbackProfile(username: string): Promise { const db = getClient(); diff --git a/src/lib/github.ts b/src/lib/github.ts index bd9bc465..ad6806a7 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1156,7 +1156,7 @@ export interface DurablePullRequestPage { } /** - * Fetch one deliberately small, cursor-addressable page for durable scan jobs. + * Fetch one deliberately small, cursor-addressable contribution page. * This is separate from the UI's recent-PR sample: its records are persisted and * eventually form the all-history native merge / workflow landing aggregate. */ diff --git a/src/lib/mcp-tools.ts b/src/lib/mcp-tools.ts index dc09563f..9a2cb99a 100644 --- a/src/lib/mcp-tools.ts +++ b/src/lib/mcp-tools.ts @@ -1,9 +1,4 @@ -/** - * Shared logic for the MCP tools (src/app/api/[transport]/route.ts). Each function - * calls the internal libs directly — never an HTTP self-call — and returns a plain - * JS object. Deterministic, read-only, and routed through the same caches the REST - * endpoints use, so an agent looping over these can't amplify GitHub/DB load. - */ +/** Shared deterministic MCP tool implementations. */ import { getAccountDetail, searchScoredUsers } from "@/lib/db"; import { getPercentileCached, getRankCached } from "@/lib/rank"; import { getLeaderboardCached } from "@/lib/leaderboard"; @@ -11,12 +6,6 @@ import type { LeaderboardCacheView } from "@/lib/redis"; import type { LeaderboardWindow } from "@/lib/db"; import { coalesceScan, getCachedScan } from "@/lib/redis"; import { buildScanResult, scanErrorResponse } from "@/lib/scan-core"; -import { - getPublicScanStatus, - publicScanAdmission, - requiresDurablePublicScan, - resolvePublicScanFromTrustedQuickScan, -} from "@/lib/public-scan"; import { SCORE_CACHE_VERSION } from "@/lib/cache-version"; import { normalizeUsername } from "@/lib/username"; import { beatPercent } from "@/lib/percentile"; @@ -36,10 +25,14 @@ async function percentileFor(finalScore: number) { : null; } -/** Deterministic score for one account (indexed hit, else a live scan). */ +async function quickScan(handle: string): Promise { + const cached = await getCachedScan(handle); + return cached ?? coalesceScan(handle, () => buildScanResult(handle)); +} + +/** Deterministic score for one account, with no asynchronous collection state. */ export async function scoreUser( rawUsername: string, - options?: { durablePrincipal?: string }, ): Promise | ToolError> { const handle = normalizeUsername(rawUsername ?? ""); if (!handle) { @@ -50,6 +43,7 @@ export async function scoreUser( if (detail) { return { source: "indexed", + coverage: "quick", stale: detail.score_version !== SCORE_CACHE_VERSION, username: detail.username, display_name: detail.display_name, @@ -63,125 +57,43 @@ export async function scoreUser( }; } - let result: ScanResult; try { - const status = await getPublicScanStatus(handle); - if (status?.status === "complete") { - result = status.scan; - } else if (status?.status === "stale") { - const s = status.scan.scoring; - const m = status.scan.metrics; - const tier = s.tier as Tier; - return { - source: "stale_public", - stale: true, - refresh_pending: status.refreshPending, - served_collection_version: status.servedCollectionVersion, - target_collection_version: status.targetCollectionVersion, - username: m.username, - display_name: m.name, - final_score: s.final_score, - tier, - tier_key: TIER_KEY[tier], - sub_scores: s.sub_scores, - red_flags: s.red_flags, - percentile: await percentileFor(s.final_score), - profile: `${SITE_URL}/u/${m.username}`, - }; - } else { - if (status) { - return { - error: "scan_enrichment_pending", - message: `durable public-history scan for ${handle} is ${status.status}; retry after ${status.retryAfterSeconds}s`, - }; - } - const cached = await getCachedScan(handle); - result = cached ?? (await coalesceScan(handle, () => buildScanResult(handle))); - } - } catch (e) { - const { error } = scanErrorResponse(e); - return { error, message: `could not score ${handle}` }; - } - - if (requiresDurablePublicScan(result)) { - const durable = await resolvePublicScanFromTrustedQuickScan( - result.metrics.username, - result, - options?.durablePrincipal ? publicScanAdmission(options.durablePrincipal) : undefined, - ); - if (durable.status === "complete") { - result = durable.scan; - } else { - return { - error: "scan_enrichment_pending", - message: `durable public-history scan for ${result.metrics.username} is ${durable.status}; retry after ${durable.retryAfterSeconds}s`, - }; - } + const result = await quickScan(handle); + const scoring = result.scoring; + const metrics = result.metrics; + const tier = scoring.tier as Tier; + return { + source: "quick", + coverage: "quick", + username: metrics.username, + display_name: metrics.name, + final_score: scoring.final_score, + tier, + tier_key: TIER_KEY[tier], + sub_scores: scoring.sub_scores, + red_flags: scoring.red_flags, + percentile: await percentileFor(scoring.final_score), + profile: `${SITE_URL}/u/${metrics.username}`, + }; + } catch (error) { + const { error: code } = scanErrorResponse(error); + return { error: code, message: `could not score ${handle}` }; } - - const s = result.scoring; - const m = result.metrics; - const tier = s.tier as Tier; - return { - source: "live", - username: m.username, - display_name: m.name, - final_score: s.final_score, - tier, - tier_key: TIER_KEY[tier], - sub_scores: s.sub_scores, - red_flags: s.red_flags, - percentile: await percentileFor(s.final_score), - profile: `${SITE_URL}/u/${m.username}`, - }; } -/** Full deterministic scan payload for one account. */ +/** Full bounded quick-scan payload for one account. */ export async function scanUser( rawUsername: string, - options?: { durablePrincipal?: string }, ): Promise { const handle = normalizeUsername(rawUsername ?? ""); if (!handle) { return { error: "invalid_username", message: "username must be a valid GitHub login" }; } try { - const status = await getPublicScanStatus(handle); - if (status?.status === "complete") return status.scan; - if (status?.status === "stale") { - const staleScan: ScanResult = { ...status.scan }; - Object.assign(staleScan, { - stale: true, - refresh_pending: status.refreshPending, - served_collection_version: status.servedCollectionVersion, - target_collection_version: status.targetCollectionVersion, - }); - return staleScan; - } - if (status) { - return { - error: "scan_enrichment_pending", - message: `durable public-history scan for ${handle} is ${status.status}; retry after ${status.retryAfterSeconds}s`, - }; - } - const cached = await getCachedScan(handle); - const scan = cached ?? (await coalesceScan(handle, () => buildScanResult(handle))); - if (requiresDurablePublicScan(scan)) { - const durable = await resolvePublicScanFromTrustedQuickScan( - scan.metrics.username, - scan, - options?.durablePrincipal ? publicScanAdmission(options.durablePrincipal) : undefined, - ); - if (durable.status === "complete") return durable.scan; - return { - error: "scan_enrichment_pending", - message: `durable public-history scan for ${scan.metrics.username} is ${durable.status}; retry after ${durable.retryAfterSeconds}s`, - }; - } - return scan; - } catch (e) { - const { error } = scanErrorResponse(e); - return { error, message: `could not scan ${handle}` }; + return await quickScan(handle); + } catch (error) { + const { error: code } = scanErrorResponse(error); + return { error: code, message: `could not scan ${handle}` }; } } @@ -189,9 +101,8 @@ export async function scanUser( export async function compareUsers( rawA: string, rawB: string, - options?: { durablePrincipal?: string }, ): Promise | ToolError> { - const [a, b] = await Promise.all([scoreUser(rawA, options), scoreUser(rawB, options)]); + const [a, b] = await Promise.all([scoreUser(rawA), scoreUser(rawB)]); if ("error" in a) return a; if ("error" in b) return b; const sa = a.final_score as number; @@ -206,7 +117,6 @@ export async function compareUsers( }; } -/** Ranked developers. `limit` keeps the payload agent-sized (the full board is 500). */ export async function getLeaderboard( view: LeaderboardCacheView = "trending", window: LeaderboardWindow = "all", @@ -217,7 +127,6 @@ export async function getLeaderboard( return { view, window, cached, count: page.length, total: entries.length, entries: page }; } -/** Prefix search over scored accounts. */ export async function searchUsers(q: string): Promise> { const query = (q ?? "").trim(); if (query.length < 1) return { query, users: [] }; diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index 69c7e68e..174fbac9 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -420,7 +420,7 @@ The Markdown report after the three control lines must be written in **English o - School, company, employer, or organization membership is background context, not score evidence, even when it appears in the profile, bio, company field, or README text. Do not write it as "therefore stronger / more trustworthy / deserving a bump" unless the data ties it to real repo quality, PR/commit work, or maintainer evidence. - AI tool use is normal modern development context, not score evidence. Even if a README self-describes ChatGPT usage, mention it only as an originality caveat when repo quality/code/usability evidence is also weak; do not frame AI use as cheating, shameful, laziness, or ghostwriting by default. - recent_prs is only the most recent merged PR sample; do not extrapolate all-time behavior from recent_prs. -- payload.signature_work is required concrete contribution evidence. Prefer all-history public-scan high-work representative repos and repeated work clusters; only treat it as a recent sample when source=recent_sample. The Ecosystem / maintenance row must cite at least one positive or neutral example from it; do not focus only on the highest-star repo, and do not ignore lower-star security/boundary/consistency/runtime/core-behavior fixes. +- payload.signature_work is required concrete contribution evidence. Prefer the strongest representative repos and repeated work clusters; only treat it as a recent sample when source=recent_sample. The Ecosystem / maintenance row must cite at least one positive or neutral example from it; do not focus only on the highest-star repo, and do not ignore lower-star security/boundary/consistency/runtime/core-behavior fixes. - Low-star repos are not automatically low-value. If signature_work notes mention a same-owner high-star ecosystem, a substantive low-star signal, high PR volume, or core-fix titles, describe the concrete work instead of calling it a toy repo, unpaid org labor, or worthless filler. In the same org/owner, high-quality smaller repos must be considered with the flagship repo as ecosystem evidence. - Repository evidence must use full \`owner/repo\` names. Do not write \`rust-lang/rust\` as "rust" or \`langgenius/dify\` as "dify"; if you mean a language or ecosystem, explicitly say "the Rust ecosystem" or "the owner ecosystem" rather than mixing it with a repo name. - Keep the body in roast mode: **TL;DR**, dimension notes, red flags, manual review, and verdict must use punchy, witty, data-grounded jabs. Anchor every jab in a number or concrete signal; do not merely list audit facts. diff --git a/src/lib/public-scan-dispatcher.ts b/src/lib/public-scan-dispatcher.ts deleted file mode 100644 index da66c5b8..00000000 --- a/src/lib/public-scan-dispatcher.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { after } from "next/server"; -import { - recordPublicScanStepMetrics, - recordPublicScanWorkerMetrics, - type PublicScanStepObservation, -} from "./db"; -import { processPublicScanJob, type PublicScanWorkerResult } from "./public-scan-worker"; -import { PUBLIC_SCAN_COLLECTION_VERSION, type PublicScanStepOutcome } from "./scan-run-types"; - -// A response-side head start is optional acceleration only. Keep it to one -// bounded unit so a high-history scan cannot make the initiating public API -// request wait behind several GitHub pages; the dedicated worker service is -// the durable throughput path. -const REQUEST_DRAIN_MAX_STEPS = 1; -const REQUEST_DRAIN_MAX_MS = 10_000; -const WORKER_DRAIN_MAX_STEPS = 1; -const WORKER_DRAIN_MAX_MS = 20 * 60 * 1_000; - -export interface PublicScanDrainResult { - processed: number; - results: PublicScanWorkerResult[]; - exhaustedBudget: boolean; -} - -type PublicScanDrainSource = "request" | "worker"; - -function stepOutcome(result: Exclude): PublicScanStepOutcome { - if (result.status === "failed") { - return result.retryScheduled ? "failed_retrying" : "failed_terminal"; - } - return result.status; -} - -function logStep( - source: PublicScanDrainSource, - result: Exclude, - durationMs: number, -): void { - console.info( - "public_scan.step", - JSON.stringify({ - source, - status: result.status, - jobId: result.jobId, - runId: result.runId, - phase: result.phase, - durationMs, - ...(result.status === "failed" ? { retryScheduled: result.retryScheduled } : {}), - }), - ); -} - -/** - * Drain short, leased units from the Turso-backed queue. There is intentionally - * no process-local queue and no network callback: every continuation has already - * been persisted by the worker before it returns. A later worker iteration can - * resume exactly where this invocation stops. - */ -export async function drainPublicScanJobs(input: { - maxSteps: number; - maxDurationMs: number; - source: PublicScanDrainSource; - targetJobId?: string; - leaseMs?: number; -}): Promise { - const maxSteps = input.targetJobId ? 1 : Math.max(1, Math.floor(input.maxSteps)); - const deadline = Date.now() + Math.max(1_000, Math.floor(input.maxDurationMs)); - const results: PublicScanWorkerResult[] = []; - const observations: PublicScanStepObservation[] = []; - - while (results.length < maxSteps && Date.now() < deadline) { - const startedAt = Date.now(); - const result = input.leaseMs - ? await processPublicScanJob({ jobId: input.targetJobId, leaseMs: input.leaseMs }) - : await processPublicScanJob(input.targetJobId); - const durationMs = Math.max(0, Date.now() - startedAt); - if (result.status === "idle") break; - results.push(result); - observations.push({ phase: result.phase, outcome: stepOutcome(result), durationMs }); - logStep(input.source, result, durationMs); - if (input.targetJobId || result.status === "slot_busy") break; - } - - await recordPublicScanStepMetrics({ - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - observations, - }); - - const processed = results.filter((result) => result.status !== "slot_busy").length; - - return { - processed, - results, - exhaustedBudget: processed >= maxSteps || Date.now() >= deadline, - }; -} - -/** - * Start a small server-side head start after a request returns. This does not - * make the browser responsible for work: the durable job is already committed - * to Turso and the dedicated worker service remains the recovery mechanism. - */ -export function kickPublicScanDrain(jobId: string): void { - after(async () => { - try { - await drainPublicScanJobs({ - maxSteps: REQUEST_DRAIN_MAX_STEPS, - maxDurationMs: REQUEST_DRAIN_MAX_MS, - source: "request", - targetJobId: jobId, - }); - } catch { - console.error( - "public_scan.request_head_start_failed", - JSON.stringify({ jobId, kind: "dispatcher_failure" }), - ); - } - }); -} - -/** Run exactly one leased unit for the long-lived worker service. */ -export async function drainPublicScanJobsFromWorker(input: { - leaseMs: number; - recordIdleHeartbeat?: boolean; -}): Promise { - const startedAt = Date.now(); - try { - const result = await drainPublicScanJobs({ - maxSteps: WORKER_DRAIN_MAX_STEPS, - maxDurationMs: WORKER_DRAIN_MAX_MS, - source: "worker", - leaseMs: input.leaseMs, - }); - const completedAt = Date.now(); - const failedSteps = result.results.filter((step) => step.status === "failed").length; - // Idle lanes poll frequently for low queue latency. Their heartbeat is - // intentionally throttled by the service process so an empty queue does - // not create a permanent stream of Turso writes and log entries. - if (result.processed > 0 || input.recordIdleHeartbeat !== false) { - await recordPublicScanWorkerMetrics({ - startedAt, - completedAt, - processed: result.processed, - failedSteps, - success: true, - }); - console.info( - "public_scan.worker", - JSON.stringify({ - status: "ok", - processed: result.processed, - failedSteps, - durationMs: completedAt - startedAt, - exhaustedBudget: result.exhaustedBudget, - }), - ); - } - return result; - } catch { - const completedAt = Date.now(); - try { - await recordPublicScanWorkerMetrics({ - startedAt, - completedAt, - processed: 0, - failedSteps: 0, - success: false, - }); - } catch { - // The fixed log line remains the health signal when storage itself cannot - // persist the failed heartbeat. - } - console.error( - "public_scan.worker", - JSON.stringify({ status: "failed", durationMs: completedAt - startedAt }), - ); - throw new Error("public scan worker drain failed"); - } -} diff --git a/src/lib/public-scan-worker.ts b/src/lib/public-scan-worker.ts deleted file mode 100644 index 682c7f06..00000000 --- a/src/lib/public-scan-worker.ts +++ /dev/null @@ -1,658 +0,0 @@ -import { createHash } from "node:crypto"; -import { - acquirePublicScanExecutionLease, - acquirePublicScanRateWindow, - claimPublicScanJob, - completePublicScanRun, - failPublicScanJob, - getNextPublicScanCommitVerificationWork, - getPublicScanContributionAggregates, - getPublicScanOwnedRepoFacts, - getPublicScanPrSummary, - getPublicScanSignaturePrFacts, - getPublicScanRun, - materializePublicScanCommitRepoFacts, - preparePublicScanCommitVerificationWork, - PublicScanStorageError, - savePublicScanJobProgress, - savePublicScanQuickResult, - splitPublicScanCommitVerificationWork, - upsertPublicScanCommitCandidates, - upsertPublicScanCommitRepoFacts, - upsertPublicScanOwnedRepoFacts, - upsertPublicScanPrFacts, - recordPublicScanCommitVerificationPage, - releasePublicScanExecutionLease, - releasePublicScanJobClaim, -} from "./db"; -import { - fetchDurableOwnedRepositoryPage, - fetchDurablePullRequestPage, - hydrateTopRepoEvidence, - listPublicDefaultBranchCommits, - searchPublicCommitCandidates, - verifyWorkflowLandedPublicScanFacts, -} from "./github"; -import { - applyPublicContributionAggregate, - applyPublicOriginalRepoInventory, - buildScanResult, -} from "./scan-core"; -import { setCachedScan } from "./redis"; -import type { - PublicScanJobPhase, - PublicScanSourceStatus, -} from "./scan-run-types"; -import { - PUBLIC_SCAN_COLLECTION_VERSION, - hasCompletePublicScanSources, -} from "./scan-run-types"; -import type { ScanResult, TopRepo } from "./types"; - -interface DiscoveryRange { - from: string; - to: string; - page: number; -} - -interface WorkerPayload { - page?: number; - after?: string | null; - mode?: "discover" | "verify"; - ranges?: DiscoveryRange[]; -} - -class NonCanonicalPublicScanError extends Error {} - -function publicScanErrorKind(error: unknown): - | "non_canonical" - | "rate_limited" - | "timeout" - | "upstream" - | "invalid_state" - | "unknown" { - if (error instanceof NonCanonicalPublicScanError) return "non_canonical"; - const message = error instanceof Error ? error.message : ""; - if (/rate.?limit|secondary rate|quota/i.test(message)) return "rate_limited"; - if (/timeout|timed out|abort/i.test(message)) return "timeout"; - if (/github|graphql|http|fetch|upstream/i.test(message)) return "upstream"; - if (/missing|malformed|unsupported|disappeared|cannot be split/i.test(message)) { - return "invalid_state"; - } - return "unknown"; -} - -export type PublicScanWorkerResult = - | { status: "idle" } - | { status: "slot_busy"; jobId: string; runId: string; phase: PublicScanJobPhase } - | { status: "continued"; jobId: string; runId: string; phase: PublicScanJobPhase } - | { status: "complete"; jobId: string; runId: string; phase: "publish" } - | { - status: "failed"; - jobId: string; - runId: string; - phase: PublicScanJobPhase; - retryScheduled: boolean; - }; - -function parsePayload(raw: string): WorkerPayload { - try { - const value = JSON.parse(raw) as WorkerPayload; - return value && typeof value === "object" ? value : {}; - } catch { - return {}; - } -} - -function parseScan(raw: string | null): ScanResult { - if (!raw) throw new Error("durable scan has no quick snapshot"); - const scan = JSON.parse(raw) as Partial; - if (!scan.metrics || !scan.scoring || !Array.isArray(scan.top_repos) || !Array.isArray(scan.recent_prs)) { - throw new Error("durable scan quick snapshot is malformed"); - } - return scan as ScanResult; -} - -function sourceStatus(runStatus: PublicScanSourceStatus): PublicScanSourceStatus { - return { ...runStatus }; -} - -function initialSources(): PublicScanSourceStatus { - return { - quick: "complete", - original_repos: "pending", - native_prs: "pending", - workflow_landings: "pending", - commit_recovery: "pending", - }; -} - -function splitRange(range: DiscoveryRange): [DiscoveryRange, DiscoveryRange] { - const from = Date.parse(range.from); - const to = Date.parse(range.to); - if (!Number.isFinite(from) || !Number.isFinite(to) || to - from <= 1_000) { - throw new Error("commit search interval cannot be split further"); - } - const middle = from + Math.floor((to - from) / 2); - return [ - { from: new Date(from).toISOString(), to: new Date(middle).toISOString(), page: 1 }, - { from: new Date(middle + 1).toISOString(), to: new Date(to).toISOString(), page: 1 }, - ]; -} - -function sourceComplete(sources: PublicScanSourceStatus): boolean { - return hasCompletePublicScanSources(sources); -} - -function toTopRepo(fact: Awaited>[number]): TopRepo { - return { - name: fact.name, - owner_login: fact.ownerLogin ?? fact.repoKey.split("/", 1)[0], - name_with_owner: fact.repoKey, - stars: fact.stars, - forks: fact.forks, - open_issues: fact.openIssues, - size: fact.size, - language: fact.language, - description: fact.description, - pushed_at: fact.pushedAt, - topics: fact.topics, - }; -} - -async function continueJob(input: { - jobId: string; - runId: string; - leaseToken: string; - phase: PublicScanJobPhase; - payload: WorkerPayload; - sources: PublicScanSourceStatus; - delaySeconds?: number; -}): Promise { - const rawPayload = JSON.stringify(input.payload); - const saved = await savePublicScanJobProgress({ - jobId: input.jobId, - runId: input.runId, - leaseToken: input.leaseToken, - phase: input.phase, - payload: rawPayload, - sourceStatus: input.sources, - nextRunAt: - input.delaySeconds && input.delaySeconds > 0 - ? Date.now() + Math.ceil(input.delaySeconds * 1_000) - : undefined, - }); - if (!saved) return { status: "idle" }; - return { - status: "continued", - jobId: input.jobId, - runId: input.runId, - phase: input.phase, - }; -} - -/** - * Execute exactly one bounded collection step. The database job lease makes the - * method safe across the dedicated worker service and request after-work; the - * caller can safely treat a stale/no-op delivery as successful. - */ -export async function processPublicScanJob( - input?: string | { jobId?: string; leaseMs?: number }, -): Promise { - const jobId = typeof input === "string" ? input : input?.jobId; - const leaseMs = typeof input === "string" ? undefined : input?.leaseMs; - const lease = await claimPublicScanJob({ - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - jobId, - ...(leaseMs === undefined ? {} : { leaseMs }), - }); - if (!lease) return { status: "idle" }; - const { job, leaseToken } = lease; - let executionSlot: number | null = null; - try { - if (job.collectionVersion !== PUBLIC_SCAN_COLLECTION_VERSION) { - throw new NonCanonicalPublicScanError( - "durable scan job uses a non-canonical collection version", - ); - } - executionSlot = await acquirePublicScanExecutionLease({ - jobId: job.id, - leaseToken, - ...(leaseMs === undefined ? {} : { leaseMs }), - }); - if (executionSlot === null) { - const released = await releasePublicScanJobClaim({ - jobId: job.id, - runId: job.runId, - leaseToken, - }); - return released - ? { status: "slot_busy", jobId: job.id, runId: job.runId, phase: job.phase } - : { status: "idle" }; - } - const run = await getPublicScanRun(job.runId); - if (!run) throw new Error("durable scan run disappeared"); - if ( - run.collectionVersion !== PUBLIC_SCAN_COLLECTION_VERSION || - run.collectionVersion !== job.collectionVersion - ) { - throw new NonCanonicalPublicScanError( - "durable scan run uses a non-canonical collection version", - ); - } - const payload = parsePayload(job.payload); - const sources = sourceStatus(run.sourceStatus); - - if (job.phase === "quick") { - const quick = await buildScanResult(job.username); - const seededSources = initialSources(); - const saved = await savePublicScanQuickResult({ - jobId: job.id, - runId: job.runId, - leaseToken, - quickScan: JSON.stringify(quick), - sourceStatus: seededSources, - }); - if (!saved) return { status: "idle" }; - // When the contribution graph succeeds, retain its commit-only - // aggregates before moving to the complete native PR inventory. The - // durable PR phase then replaces only the PR portion; without this seed - // a high-PR user would lose valid commit-only impact at publication. - const graphCommitFacts = (quick.impact_repos ?? []) - .filter((repo) => repo.commits > 0) - .map((repo) => ({ - repoKey: repo.repo, - ownerLogin: repo.repo.split("/", 1)[0] ?? null, - stars: repo.stars, - // `impact_repos` already passed the GraphQL collector's visibility - // filtering. Commit-search recovery carries its own REST flags below. - isPrivate: false, - isFork: false, - commits: repo.commits, - activeYears: 0, - firstCommittedAt: null, - lastCommittedAt: null, - source: "contribution_graph" as const, - evidenceShas: [], - })); - if ( - !(await upsertPublicScanCommitRepoFacts({ - jobId: job.id, - runId: job.runId, - leaseToken, - facts: graphCommitFacts, - })) - ) { - return { status: "idle" }; - } - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "original_repos", - payload: { page: 1 }, - sources: seededSources, - }); - } - - const quick = parseScan(run.quickScan); - - if (job.phase === "original_repos") { - const page = Math.max(1, payload.page ?? 1); - const result = await fetchDurableOwnedRepositoryPage({ username: job.username, page }); - const stored = await upsertPublicScanOwnedRepoFacts({ - jobId: job.id, - runId: job.runId, - leaseToken, - facts: result.facts, - }); - if (!stored) return { status: "idle" }; - if (result.hasNextPage) { - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "original_repos", - payload: { page: page + 1 }, - sources, - }); - } - sources.original_repos = "complete"; - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "merged_prs", - payload: {}, - sources, - }); - } - - if (job.phase === "merged_prs") { - const page = await fetchDurablePullRequestPage({ - username: job.username, - state: "MERGED", - after: payload.after ?? null, - }); - const stored = await upsertPublicScanPrFacts({ - jobId: job.id, - runId: job.runId, - leaseToken, - facts: page.facts, - }); - if (!stored) return { status: "idle" }; - if (page.hasNextPage && page.endCursor) { - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "merged_prs", - payload: { after: page.endCursor }, - sources, - }); - } - sources.native_prs = "complete"; - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "workflow_landings", - payload: {}, - sources, - }); - } - - if (job.phase === "workflow_landings") { - const page = await fetchDurablePullRequestPage({ - username: job.username, - state: "CLOSED", - after: payload.after ?? null, - includeLabels: true, - }); - const stored = await upsertPublicScanPrFacts({ - jobId: job.id, - runId: job.runId, - leaseToken, - facts: page.facts, - }); - if (!stored) return { status: "idle" }; - const candidates = page.facts - .filter((fact) => fact.labels.some((label) => label.trim().toLowerCase() === "merged")) - .map((fact) => fact.pullRequestId); - for (let i = 0; i < candidates.length; i += 25) { - const verified = await verifyWorkflowLandedPublicScanFacts(candidates.slice(i, i + 25)); - if ( - verified.length > 0 && - !(await upsertPublicScanPrFacts({ - jobId: job.id, - runId: job.runId, - leaseToken, - facts: verified, - })) - ) { - return { status: "idle" }; - } - } - if (page.hasNextPage && page.endCursor) { - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "workflow_landings", - payload: { after: page.endCursor }, - sources, - }); - } - sources.workflow_landings = "complete"; - // The normal contribution graph already supplied its bounded - // commit-only aggregate. REST commit recovery is only a fallback for the - // GraphQL resource-limit failure; running it for every prolific PR author - // would spend the scarce Search quota without adding new coverage. - if (!quick.metrics.commit_contribution_aggregation_unavailable) { - sources.commit_recovery = "complete"; - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "publish", - payload: {}, - sources, - }); - } - const from = quick.metrics.created_at ?? "2008-01-01T00:00:00.000Z"; - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "commit_recovery", - payload: { mode: "discover", ranges: [{ from, to: new Date().toISOString(), page: 1 }] }, - sources, - }); - } - - if (job.phase === "commit_recovery") { - if ((payload.mode ?? "discover") === "discover") { - const ranges = payload.ranges ?? []; - const current = ranges[0]; - if (!current) { - const prepared = await preparePublicScanCommitVerificationWork({ - jobId: job.id, - runId: job.runId, - leaseToken, - }); - if (!prepared) return { status: "idle" }; - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "commit_recovery", - payload: { mode: "verify" }, - sources, - }); - } - // Commit Search has a much smaller global quota than normal GitHub REST - // reads. Reserve from Turso before each page, so retried Cron work and - // a Redis outage cannot stampede the shared token. - const searchBudget = await acquirePublicScanRateWindow({ - bucket: "github-commit-search", - limit: 20, - windowMs: 60_000, - }); - if (!searchBudget.granted) { - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "commit_recovery", - payload: { mode: "discover", ranges }, - sources, - delaySeconds: Math.max(2, Math.ceil((searchBudget.retryAt - Date.now()) / 1_000)), - }); - } - const found = await searchPublicCommitCandidates({ - username: job.username, - from: current.from, - to: current.to, - page: current.page, - }); - if (found.incompleteResults || found.totalCount > 1_000) { - const [left, right] = splitRange(current); - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "commit_recovery", - payload: { mode: "discover", ranges: [left, right, ...ranges.slice(1)] }, - sources, - }); - } - const stored = await upsertPublicScanCommitCandidates({ - jobId: job.id, - runId: job.runId, - leaseToken, - candidates: found.candidates, - }); - if (!stored) return { status: "idle" }; - const morePages = current.page * 100 < found.totalCount; - const nextRanges = morePages - ? [{ ...current, page: current.page + 1 }, ...ranges.slice(1)] - : ranges.slice(1); - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "commit_recovery", - payload: { mode: "discover", ranges: nextRanges }, - sources, - }); - } - - const work = await getNextPublicScanCommitVerificationWork(job.runId); - if (!work) { - const materialized = await materializePublicScanCommitRepoFacts({ - jobId: job.id, - runId: job.runId, - leaseToken, - }); - if (!materialized) return { status: "idle" }; - sources.commit_recovery = "complete"; - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "publish", - payload: {}, - sources, - }); - } - const verified = await listPublicDefaultBranchCommits({ - repoKey: work.repoKey, - username: job.username, - from: work.from, - to: work.to, - page: work.page, - }); - if (verified.hasNextPage && work.page >= 10) { - const [left, right] = splitRange({ from: work.from, to: work.to, page: 1 }); - const split = await splitPublicScanCommitVerificationWork({ - jobId: job.id, - runId: job.runId, - leaseToken, - work, - left, - right, - }); - if (!split) return { status: "idle" }; - } else { - const saved = await recordPublicScanCommitVerificationPage({ - jobId: job.id, - runId: job.runId, - leaseToken, - work, - commits: verified.commits, - complete: !verified.hasNextPage, - }); - if (!saved) return { status: "idle" }; - } - return continueJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - phase: "commit_recovery", - payload: { mode: "verify" }, - sources, - }); - } - - if (job.phase === "publish") { - if (!sourceComplete(sources)) { - throw new Error("cannot publish a partial public scan"); - } - const [aggregates, summary, ownedFacts, signaturePrFacts] = await Promise.all([ - getPublicScanContributionAggregates(job.runId), - getPublicScanPrSummary(job.runId, job.username), - getPublicScanOwnedRepoFacts(job.runId), - getPublicScanSignaturePrFacts(job.runId), - ]); - const owned = ownedFacts.map(toTopRepo); - const candidates = [ - ...owned.slice(0, 12), - ...[...owned].sort((a, b) => b.size - a.size || b.stars - a.stars).slice(0, 12), - ]; - const evidenceRepos = [...new Map(candidates.map((repo) => [repo.name_with_owner!, repo])).values()]; - await hydrateTopRepoEvidence(evidenceRepos, job.username, 24); - const evidenceByName = new Map(evidenceRepos.map((repo) => [repo.name_with_owner!, repo])); - const enrichedOwned = owned.map((repo) => evidenceByName.get(repo.name_with_owner!) ?? repo); - const contributionRepos = aggregates.map((repo) => ({ - repo: repo.repo, - stars: repo.stars, - is_private: repo.isPrivate, - is_fork: repo.isFork, - owner_login: repo.ownerLogin, - commits: repo.commits, - prs: repo.prs, - active_years: repo.activeYears, - })); - const withOriginalInventory = applyPublicOriginalRepoInventory(quick, enrichedOwned); - const completed = applyPublicContributionAggregate(withOriginalInventory, contributionRepos, { - total: summary.workflowLandedPrs, - impact: summary.workflowLandedImpactPrs, - }, signaturePrFacts); - const snapshot = JSON.stringify(completed); - const published = await completePublicScanRun({ - jobId: job.id, - runId: job.runId, - leaseToken, - coverage: "complete_public", - sourceStatus: sources, - snapshot, - snapshotHash: createHash("sha256").update(snapshot).digest("hex"), - }); - if (!published) return { status: "idle" }; - await setCachedScan(completed.metrics.username, completed); - return { status: "complete", jobId: job.id, runId: job.runId, phase: "publish" }; - } - - throw new Error(`unsupported durable scan phase: ${job.phase}`); - } catch (error) { - if (error instanceof PublicScanStorageError) throw error; - const kind = publicScanErrorKind(error); - const retryAt = - !(error instanceof NonCanonicalPublicScanError) && job.attemptCount < 4 - ? Date.now() + 5_000 * 2 ** job.attemptCount - : undefined; - const failed = await failPublicScanJob({ - jobId: job.id, - runId: job.runId, - leaseToken, - error: `worker_${kind}`, - retryAt, - }); - if (!failed) return { status: "idle" }; - console.error( - "public_scan.step_failed", - JSON.stringify({ - jobId: job.id, - runId: job.runId, - phase: job.phase, - kind, - retryScheduled: retryAt !== undefined, - }), - ); - return { - status: "failed", - jobId: job.id, - runId: job.runId, - phase: job.phase, - retryScheduled: retryAt !== undefined, - }; - } finally { - if (executionSlot !== null) { - await releasePublicScanExecutionLease({ - slot: executionSlot, - jobId: job.id, - leaseToken, - }); - } - } -} diff --git a/src/lib/public-scan.ts b/src/lib/public-scan.ts deleted file mode 100644 index ed117722..00000000 --- a/src/lib/public-scan.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { createHash } from "node:crypto"; -import { - enqueuePublicScan, - getCompletePublicScanRuns, - getLatestPublicScanRun, - seedPublicScanQuickResult, - type PublicScanAdmission, -} from "./db"; -import { SCORE_CACHE_VERSION } from "./cache-version"; -import { RELEASE_VERSION_MANIFEST } from "./release-versions"; -import { - PUBLIC_SCAN_COLLECTION_VERSION, - hasCompletePublicScanSources, - type PublicScanRun, -} from "./scan-run-types"; -import { score } from "./score"; -import type { ScanResult } from "./types"; - -const FAILED_RUN_RETRY_MS = 15 * 60 * 1_000; -const PUBLIC_SCAN_ADMISSION_WINDOW_MS = 60 * 60 * 1_000; -const PUBLIC_SCAN_ADMISSION_LIMIT = 2; -const PUBLIC_SCAN_MAX_ACTIVE_JOBS = 24; - -const REQUIRED_NUMERIC_METRICS = [ - "account_age_years", - "followers", - "following", - "public_repos", - "fetched_repo_count", - "original_repo_count", - "nonempty_original_repo_count", - "fork_repo_count", - "empty_original_repo_count", - "total_stars", - "max_stars", - "merged_pr_count", - "total_pr_count", - "issues_created", - "last_year_contributions", - "activity_type_count", - "contribution_years_active", - "recent_merged_pr_sample", - "recent_trivial_pr_count", - "external_trivial_pr_count", - "max_impact_repo_stars", - "impact_pr_count", - "impact_depth_raw", - "closed_unmerged_pr_count", - "pr_rejection_rate", - "recent_pr_sample", - "top_repo_pr_share", - "templated_pr_ratio", -] as const; - -const REQUIRED_SUB_SCORE_KEYS = [ - "account_maturity", - "original_project_quality", - "contribution_quality", - "ecosystem_impact", - "community_influence", - "activity_authenticity", -] as const; - -const VALID_TIERS = new Set(["夯", "顶级", "人上人", "NPC", "拉完了"]); - -function hasUsableSnapshotShape(scan: Partial): scan is ScanResult { - if ( - !scan.metrics || - typeof scan.metrics.username !== "string" || - !Array.isArray(scan.top_repos) || - !Array.isArray(scan.recent_prs) || - !Array.isArray(scan.flood_pr_titles) - ) { - return false; - } - return REQUIRED_NUMERIC_METRICS.every((key) => Number.isFinite(scan.metrics![key])); -} - -function hasStoredSnapshotScore(scan: Partial): scan is ScanResult { - const scoring = scan.scoring; - return Boolean( - scoring && - Number.isFinite(scoring.final_score) && - Number.isFinite(scoring.base_score) && - Number.isFinite(scoring.total_penalty) && - VALID_TIERS.has(scoring.tier) && - typeof scoring.tier_label === "string" && - scoring.sub_scores && - typeof scoring.sub_scores === "object" && - REQUIRED_SUB_SCORE_KEYS.every((key) => Number.isFinite(scoring.sub_scores[key])) && - Array.isArray(scoring.red_flags) && - scoring.red_flags.every( - (flag) => - flag && - typeof flag.flag === "string" && - typeof flag.detail === "string" && - Number.isFinite(flag.penalty), - ), - ); -} - -export type PublicScanResolution = - | { status: "complete"; run: PublicScanRun; scan: ScanResult } - | { - status: "stale"; - run: PublicScanRun; - scan: ScanResult; - refreshPending: boolean; - refreshRun: PublicScanRun | null; - servedCollectionVersion: string; - targetCollectionVersion: string; - } - | { - status: "pending"; - run: PublicScanRun; - retryAfterSeconds: number; - /** Present only for the request that atomically created this job. */ - headStartJobId: string | null; - } - | { status: "queue_full" | "admission_limited"; run: null; retryAfterSeconds: number } - | { status: "storage_unavailable"; run: PublicScanRun | null; retryAfterSeconds: number } - | { status: "failed"; run: PublicScanRun; retryAfterSeconds: number }; - -export type CanonicalPublicScanResolution = Exclude< - PublicScanResolution, - { status: "stale" } ->; - -/** - * Build a privacy-preserving, persistent admission key for *new* durable jobs. - * Raw IP addresses and Bearer credentials never reach the database; cache hits, - * completed scans, and status reads do not consume this budget. - */ -export function publicScanAdmission(principal: string): PublicScanAdmission { - const normalized = principal.trim() || "anonymous"; - const digest = createHash("sha256") - .update( - `ghfind-public-scan-admission-v1\0${PUBLIC_SCAN_COLLECTION_VERSION}\0${normalized}`, - ) - .digest("hex"); - return { - bucket: `durable-admission:${PUBLIC_SCAN_COLLECTION_VERSION}:${digest}`, - limit: PUBLIC_SCAN_ADMISSION_LIMIT, - windowMs: PUBLIC_SCAN_ADMISSION_WINDOW_MS, - maxActiveJobs: PUBLIC_SCAN_MAX_ACTIVE_JOBS, - }; -} - -function parseCompleteSnapshot( - run: PublicScanRun, - expectedCollectionVersion: string, - recomputeScore: boolean, -): ScanResult | null { - if ( - run.collectionVersion !== expectedCollectionVersion || - run.state !== "complete_public" || - run.coverage !== "complete_public" || - !run.snapshot || - !run.snapshotHash || - !hasCompletePublicScanSources(run.sourceStatus) - ) { - return null; - } - const actualHash = createHash("sha256").update(run.snapshot).digest("hex"); - if (actualHash !== run.snapshotHash) return null; - try { - const scan = JSON.parse(run.snapshot) as Partial; - if (!hasUsableSnapshotShape(scan)) return null; - if (!recomputeScore && !hasStoredSnapshotScore(scan)) return null; - if (scan.metrics.username.trim().toLowerCase() !== run.username.trim().toLowerCase()) return null; - // A complete public-history snapshot is a durable factual input. Score - // formulas deliberately evolve faster than collectors, so recompute the - // deterministic result at read time instead of forcing another historical - // GitHub crawl solely because SCORE_CACHE_VERSION changed. - return recomputeScore ? { ...scan, scoring: score(scan.metrics) } : (scan as ScanResult); - } catch { - return null; - } -} - -async function latestCompleteSnapshot( - username: string, - collectionVersion: string, - recomputeScore: boolean, -): Promise<{ run: PublicScanRun; scan: ScanResult } | null> { - const runs = await getCompletePublicScanRuns(username, collectionVersion); - for (const run of runs) { - const scan = parseCompleteSnapshot(run, collectionVersion, recomputeScore); - if (scan) return { run, scan }; - } - return null; -} - -function previousCollectionVersion(): string | null { - const [target, previous] = RELEASE_VERSION_MANIFEST.compatibility.collectionReadOrder; - if ( - target !== PUBLIC_SCAN_COLLECTION_VERSION || - previous !== RELEASE_VERSION_MANIFEST.previousRelease.collection || - previous === PUBLIC_SCAN_COLLECTION_VERSION - ) { - return null; - } - return previous; -} - -/** - * The quick collector is intentionally bounded. These signals mean it cannot - * honestly describe the full public history. Callers may show its deterministic - * score as a transient quick result, but must wait for the durable run before - * writing a formal score, report, or leaderboard row. - */ -export function requiresDurablePublicScan(scan: ScanResult): boolean { - const metrics = scan.metrics; - return Boolean( - metrics.commit_contribution_aggregation_unavailable || - metrics.merged_pr_contribution_aggregation_incomplete || - metrics.merged_pr_count > metrics.recent_merged_pr_sample || - metrics.merged_pr_count > 300 || - metrics.total_pr_count > 600 || - metrics.public_repos > metrics.fetched_repo_count, - ); -} - -function retryAfter(run: PublicScanRun): number { - if (run.state === "failed") { - return Math.max(1, Math.ceil((run.updatedAt + FAILED_RUN_RETRY_MS - Date.now()) / 1_000)); - } - return 5; -} - -/** - * Return the immutable current-version complete snapshot if available, else - * enqueue/resume one durable job. The database remains both the source of truth - * and the queue; request after-work and the deployment Cron drain it server-side. - */ -async function resolvePublicScan( - username: string, - quickScan?: ScanResult, - admission?: PublicScanAdmission, -): Promise { - const versions = { - scoreVersion: SCORE_CACHE_VERSION, - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }; - const existing = await getLatestPublicScanRun(username, versions); - if (existing) { - const complete = parseCompleteSnapshot(existing, PUBLIC_SCAN_COLLECTION_VERSION, true); - if (complete) return { status: "complete", run: existing, scan: complete }; - } - const canonicalComplete = await latestCompleteSnapshot( - username, - PUBLIC_SCAN_COLLECTION_VERSION, - true, - ); - if (canonicalComplete) { - return { status: "complete", ...canonicalComplete }; - } - if (existing) { - if (existing.state === "failed" && Date.now() - existing.updatedAt < FAILED_RUN_RETRY_MS) { - return { status: "failed", run: existing, retryAfterSeconds: retryAfter(existing) }; - } - } - - const enqueued = await enqueuePublicScan(username, { ...versions, admission }); - if (!enqueued) { - // Turso itself is unavailable. Do not pretend a durable queue exists. - return { status: "storage_unavailable", run: existing ?? null, retryAfterSeconds: 30 }; - } - if ("rejection" in enqueued) { - return { - status: enqueued.rejection, - run: null, - retryAfterSeconds: Math.max(1, Math.ceil((enqueued.retryAt - Date.now()) / 1_000)), - }; - } - if (quickScan && enqueued.created) { - await seedPublicScanQuickResult({ - jobId: enqueued.job.id, - runId: enqueued.run.id, - quickScan: JSON.stringify(quickScan), - sourceStatus: { - quick: "complete", - original_repos: "pending", - native_prs: "pending", - workflow_landings: "pending", - commit_recovery: "pending", - }, - }); - } - return { - status: "pending", - run: enqueued.run, - retryAfterSeconds: retryAfter(enqueued.run), - headStartJobId: enqueued.created ? enqueued.job.id : null, - }; -} - -/** - * Start durable collection without accepting any caller-supplied scan data. - * Use this from routes that only have an untrusted request body; the worker's - * quick phase will collect the first server-authored snapshot itself. - */ -export async function startPublicScan( - username: string, - admission?: PublicScanAdmission, -): Promise { - return resolvePublicScan(username, undefined, admission); -} - -/** - * Reuse a scan only when it was collected by this server in the current request - * or loaded from its own cache. This function is intentionally separate from - * {@link startPublicScan}: HTTP request bodies must never seed durable facts. - */ -export async function resolvePublicScanFromTrustedQuickScan( - username: string, - quickScan: ScanResult, - admission?: PublicScanAdmission, -): Promise { - return resolvePublicScan(username, quickScan, admission); -} - -export async function getCompletedPublicScan(username: string): Promise { - const complete = await latestCompleteSnapshot( - username, - PUBLIC_SCAN_COLLECTION_VERSION, - true, - ); - return complete?.scan ?? null; -} - -/** Read-only status probe for a job already requested through POST /api/scan. */ -export async function getPublicScanStatus(username: string): Promise { - const run = await getLatestPublicScanRun(username, { - scoreVersion: SCORE_CACHE_VERSION, - collectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }); - const scan = run - ? parseCompleteSnapshot(run, PUBLIC_SCAN_COLLECTION_VERSION, true) - : null; - if (scan && run) return { status: "complete", run, scan }; - const canonicalComplete = await latestCompleteSnapshot( - username, - PUBLIC_SCAN_COLLECTION_VERSION, - true, - ); - if (canonicalComplete) return { status: "complete", ...canonicalComplete }; - - const fallbackVersion = previousCollectionVersion(); - if (fallbackVersion) { - const stale = await latestCompleteSnapshot(username, fallbackVersion, false); - if (stale) { - const refreshPending = run?.state === "queued" || run?.state === "running"; - return { - status: "stale", - ...stale, - refreshPending, - refreshRun: run ?? null, - servedCollectionVersion: fallbackVersion, - targetCollectionVersion: PUBLIC_SCAN_COLLECTION_VERSION, - }; - } - } - - if (!run) return null; - if (run.state === "failed" && Date.now() - run.updatedAt < FAILED_RUN_RETRY_MS) { - return { status: "failed", run, retryAfterSeconds: retryAfter(run) }; - } - if (run.state === "queued" || run.state === "running") { - return { status: "pending", run, retryAfterSeconds: retryAfter(run), headStartJobId: null }; - } - // Corrupt/legacy terminal rows must be repaired by resolvePublicScan rather - // than reported as endlessly pending when no active job remains. - return null; -} diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 498763e0..0aa70795 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -31,7 +31,6 @@ import type { RoastJudgeResult, RoastLine, ScanResult } from "./types"; let redis: Redis | null = null; let scanLimiter: Ratelimit | null = null; -let publicScanStatusLimiter: Ratelimit | null = null; let campaignLeaderboardReadLimiter: Ratelimit | null = null; let mcpLimiter: Ratelimit | null = null; let roastRequestLimiter: Ratelimit | null = null; @@ -123,9 +122,7 @@ export async function setCachedScan(username: string, scan: ScanResult): Promise } } -/** Remove a bounded quick snapshot when it is corrupt or superseded. A durable - * run intentionally keeps its trusted quick snapshot available for immediate - * provisional responses; formal writes still require the complete snapshot. */ +/** Remove a bounded quick snapshot when it is corrupt or superseded. */ export async function clearCachedScan(username: string): Promise { const r = getRedis(); if (!r) return; @@ -270,34 +267,6 @@ export async function checkRateLimit(ip: string): Promise { } } -/** - * Durable scan status is polled by the browser while historical collection is - * pending. Keep its budget separate from new scan admission: polling must not - * consume the user's scan allowance, but it also must not become a free Turso - * and Function invocation amplifier. - */ -export async function checkPublicScanStatusRateLimit(ip: string): Promise { - const r = getRedis(); - if (!r) return unavailableRateLimitResult("public_scan_status", "missing_redis_config"); - if (!publicScanStatusLimiter) { - publicScanStatusLimiter = new Ratelimit({ - redis: r, - limiter: Ratelimit.slidingWindow(20, "60 s"), - prefix: "rl:public-scan-status", - analytics: false, - }); - } - try { - const { success, limit, remaining, reset } = await publicScanStatusLimiter.limit(ip); - return { success, limit, remaining, reset }; - } catch (error) { - return unavailableRateLimitResult( - "public_scan_status", - error instanceof Error ? error.name : "redis_request_failed", - ); - } -} - /** * Public event leaderboard refreshes fan out from many browsers that may share * one venue NAT. Keep them off the scan budget while still bounding origin reads. diff --git a/src/lib/score-materialization.ts b/src/lib/score-materialization.ts index 6c0ce2fc..49cabb36 100644 --- a/src/lib/score-materialization.ts +++ b/src/lib/score-materialization.ts @@ -1,11 +1,7 @@ import { createHash } from "node:crypto"; import { SCORE_CACHE_VERSION } from "./cache-version"; -import { - PUBLIC_SCAN_COLLECTION_VERSION, - hasCompletePublicScanSources, - type PublicScanSourceStatus, -} from "./scan-run-types"; +import { PUBLIC_SCAN_COLLECTION_VERSION, type PublicScanSourceStatus } from "./scan-run-types"; import { score, spamBotScore } from "./score"; import type { ImpactRepo, @@ -350,18 +346,6 @@ function hasValidScanResult(value: unknown): value is ScanResult { return optionalMatches(value, "organizations", isStringArray); } -function quickScanRequiresDurableCollection(scan: ScanResult): boolean { - const metrics = scan.metrics; - return Boolean( - metrics.commit_contribution_aggregation_unavailable || - metrics.merged_pr_contribution_aggregation_incomplete || - metrics.merged_pr_count > metrics.recent_merged_pr_sample || - metrics.merged_pr_count > 300 || - metrics.total_pr_count > 600 || - metrics.public_repos > metrics.fetched_repo_count, - ); -} - /** * Validate one immutable scan snapshot and derive its current deterministic score. * No caller-provided score, report, tag, or roast text is retained. @@ -384,19 +368,6 @@ export function materializeCanonicalScore( return null; } - if ( - input.mode === "durable" && - (!input.sourceStatus || !hasCompletePublicScanSources(input.sourceStatus)) - ) { - return null; - } - if ( - input.sourceStatus && - !hasCompletePublicScanSources(input.sourceStatus) - ) { - return null; - } - let parsed: unknown; try { parsed = JSON.parse(input.snapshot); @@ -408,8 +379,6 @@ export function materializeCanonicalScore( const requestedUsername = normalizeUsername(input.username)?.toLowerCase(); const snapshotUsername = normalizeUsername(parsed.metrics.username)?.toLowerCase(); if (!requestedUsername || requestedUsername !== snapshotUsername) return null; - if (input.mode === "quick" && quickScanRequiresDurableCollection(parsed)) return null; - const scoring = score(parsed.metrics); const scan: ScanResult = { ...parsed, scoring }; return {