Recipes / Event-driven

AI code review

A FlareDispatch port of Cloudflare's multi-agent code reviewer — up to seven domain-specific agents review every PR (`pr-review.agents=multi`), findings deduplicated into one consolidated review; or one generalist reviewer (`agents=single`) for a leaner pass. Backend is selectable from CONFIG_KV without redeploy (Workers AI, Anthropic-via-AI-Gateway BYOK, or Bedrock via the OIDC→STS→SigV4 BYOC trust path), and a dispatch can override the model/region/role per call for model bake-offs. The GitHub App webhook fires it directly on every push — no `.github/workflows/` file and zero GHA minutes; the `ci.yml` shown is the optional Action-mode alternative, not a requirement. An optional Schedule-mode sweep re-reviews every open PR on a cron cadence.

Fires on Every PR push — wire it any of these ways:

  • Webhook recommended The GitHub App webhook fires the run directly — no .github/workflows file, no GHA minutes.
  • Action A ci.yml step dispatches the run — when it must interleave with other CI jobs or gate the PR.
  • Schedule A Cloudflare Cron Trigger fires it on a wall-clock cadence — no GitHub event, no workflow file. (the pr-review-sweep run re-reviews open PRs on a cron)
Use case
Configurable single- or multi-agent code review on every PR — plus a nightly sweep
FlareDispatch shape
2 typed runs + dispatch
Plain GHA shape
1 workflow · 5 jobs + matrix + REST glue

Recipe: AI code review on every PR

A FlareDispatch port of Cloudflare’s multi-agent code reviewer — blog.cloudflare.com/ai-code-review. The blog’s system reviews merge requests with up to seven domain-specific agents, deduplicates their findings, and posts one consolidated review. This recipe implements the same shape as a FlareDispatch run.

Why Webhook mode

AI review should fire on every PR push, on every repo, without anyone editing .github/workflows/ and without burning GHA minutes. That is exactly Webhook mode: the FlareDispatch GitHub App webhook fires the run directly. The recipe is therefore a single DSL file — pr-review.run.ts — dropped into your repo’s runs/. No workflow file.

How the blog’s design maps onto the run

Blog conceptIn pr-review.run.ts
Triggered on merge-request open / updatetriggers on pull_request actions opened, synchronize, ready_for_review
Noise filtering — lockfiles, minified, generatedprepare-diff step → plain git diff, then stripDiffNoise drops lockfile / minified / generated / vendored file sections in the Worker
Risk tiers — trivial / lite / fullclassify-risk step → the engine’s pure riskTier heuristic (diff size + sensitive paths); Match on the tier selects the agent set (1 / 4 / 7)
Cheaper model on trivial diffsthe tier gates how many domain reviewers fan out; the model is the operator-pinned backend (see below)
Workers + KV control plane — model routing without redeployresolve-backend step → config.get("pr-review.backend") + the backend’s model / mode keys (03-dsl § config) — repoint a backend in seconds, no redeploy
Up to seven domain-specific agents, each tightly scopedFULL_AGENTS; the tier’s subset is fanned out in the review step (Effect.forEach with concurrency), each calling reviewDomain in the Worker
Shared context to cut token duplicationthe single noise-stripped, size-capped diff passed to every domain reviewer
Coordinator dedups + filters into one verdictcoordinate step → the engine’s coordinate, pure deterministic code (dedup + counts-by-severity + verdict-by-rule) — no model call. The current run is authoritative (no carry-over), so a fixed finding clears on its own
Single consolidated reviewa PR review comment (github.pullReview, event: COMMENT) posted on every run — success AND failure — plus the check-run summary
Inline comments on specific lineseach Finding in the run output → a check-run annotation (04-gha-integration § Inline findings)
Bias toward approval unless critical findingsthe verdict rule in coordinate: any failure → request-changes; else any warning → comment; else approve
Provider-agnostic model client@flare-dispatch/review-agent calls the model through the modelGateway capability — backed by the Cloudflare Workers AI binding (env.AI) via an AI Gateway. The binding is the auth (account-billed); no model API key

Flow

flowchart LR
  PR[PR push] -->|App webhook| DSP[Dispatcher]
  DSP --> CO[checkout]
  CO --> PD[prepare-diff<br/>strip noise]
  PD --> RT[classify-risk]
  RT --> PLAN{tier?}
  PLAN -->|trivial / lite / full| FAN[review<br/>tier's agent set, parallel]
  FAN --> CRD[coordinate<br/>dedup + verdict]
  CRD --> CHK[check-run<br/>summary + inline annotations]

Framework surface this recipe relies on

The run is deliberately thin — it orchestrates, it does not contain model logic. Three pieces of the framework carry the weight:

  • config (03-dsl) — a KV-backed control plane. The reviewer backend (model id + mode) is resolved at run time, so an operator can repoint it at a fallback in seconds when a model degrades — no redeploy. This is the seam for the blog’s “Workers + KV control plane.”
  • modelGateway (03-dsl) — the model-calling capability, backed by the Cloudflare Workers AI binding (env.AI) via an AI Gateway. The binding is the auth (account-billed), so the run carries no model API key. The engine yields the Tag; the runtime provides it ambiently, like config / sandbox.
  • Check-run annotations (04-gha-integration) — the run returns a findings array; the Dispatcher posts each as an inline annotation on the PR’s Files-changed tab. The GitHub-native equivalent of GitLab’s per-line DiffNotes, with no separate review thread to manage.

The review engine runs in the Worker

The review is performed in the Worker run body, not in a container CLI. The single container image (infra/Dockerfile.sandbox: Node + git + curl) is used only for git (checkout + git diff). The engine is @flare-dispatch/review-agent: riskTier (a pure heuristic, no model call), reviewDomain (the one model-calling surface — calls the model through the modelGateway capability, backed by the Cloudflare Workers AI binding via an AI Gateway, no API key), and coordinate (pure deterministic assembly — dedup + counts + verdict-by-rule, no model call). reviewDomain’s findings come from a Schema-validated tool call (tools mode) or a parsed strict-JSON text response (json mode).

Earlier versions shelled out to a review-agent CLI that did not exist in the deployed image, so every review silently failed. Moving the engine into the Worker removes that dependency entirely.

Configurable backend

The engine selects a model backend from config — repoint it in seconds, no redeploy. No API key — the model is called through the modelGateway capability (the Workers AI binding is the auth):

Key (CONFIG_KV)Meaning
pr-review.backendworkers-ai (default), anthropic, or bedrock
pr-review.prompt(optional) REPLACE the generic per-domain reviewer system prompt
pr-review.guidelines(optional) ADDITIVE house rules appended on top of the reviewer prompt — a suppression rubric (“what NOT to flag”), project conventions, or severity calibration. Layers onto the maintained default instead of replacing it.
pr-review.workers-ai.modelbare Workers AI catalog id (e.g. @cf/meta/llama-3.3-70b-instruct-fp8-fast, account-billed, no key) or a deepseek/-prefixed hosted reasoner (e.g. deepseek/deepseek-reasoner, BYOK via AI Gateway; routes via the AI Gateway universal endpoint with a BYOK DeepSeek key stored in the gateway; requires AI_GATEWAY_ID)
pr-review.workers-ai.modetools (default) or json — pin json for reasoning models (DeepSeek-class models ignore tool-calls)
pr-review.anthropic.modelanthropic/-prefixed model id, e.g. anthropic/claude-sonnet-4-6. Routes via the AI Gateway universal endpoint (BYOK Anthropic key stored in the gateway). Requires AI_GATEWAY_ID.
pr-review.anthropic.modetools (default) or json
pr-review.bedrock.modelbedrock/-prefixed model id, e.g. bedrock/us.anthropic.claude-opus-4-6-v1. Routes via the AI Gateway Bedrock forwarder; SigV4-signed with short-lived STS creds (BYOC trust path). Requires AI_GATEWAY_ID + CLOUDFLARE_ACCOUNT_ID + OIDC_SIGNING_JWK + OIDC_ISSUER_URL.
pr-review.bedrock.regionAWS region (default us-east-1)
pr-review.bedrock.roleArnIAM role ARN to AssumeRoleWithWebIdentity into. Trust policy MUST pin sub: pr-review:*.

Model ids are bare @cf/... for the Workers AI catalog, anthropic/... or deepseek/... for the AI-Gateway universal endpoint (BYOK), or bedrock/... for the AI-Gateway Bedrock forwarder. An AI Gateway can front Workers AI calls by setting the AI_GATEWAY_ID var on the Worker; the same AI_GATEWAY_ID is used for the anthropic/, deepseek/ and bedrock/ routes (required, not optional).

A misconfigured backend (no model key, or for bedrock no roleArn) fails fast — the run posts a PR comment naming the exact missing key.

Bedrock backend — the BYOC trust path

The bedrock backend mints short-lived AWS credentials per execution via OIDC federation — no long-lived AWS key in GHA or the Worker. The dispatcher signs an OIDC JWT, AWS STS exchanges it for short-lived creds, then the run SigV4-signs bedrock:InvokeModel and POSTs through the AI Gateway Bedrock forwarder (the gateway forwards the SigV4 Authorization header verbatim, never holds the creds).

flowchart LR
  W[FlareDispatch Worker] -->|"oidc.sign({ aud: sts.amazonaws.com })"| JWT[Signed JWT<br/>iss = OIDC_ISSUER_URL<br/>sub = pr-review:&lt;exec&gt;]
  JWT -->|AssumeRoleWithWebIdentity| STS[AWS STS]
  STS -->|short-lived creds| W
  W -->|"SigV4-signed POST"| AIG[AI Gateway<br/>aws-bedrock forwarder]
  AIG -->|forwards verbatim| BR[bedrock-runtime:InvokeModel]

Two factors gate role assumption — the dispatch HMAC and the JWT signature — so a leaked HMAC alone cannot mint Bedrock creds. AWS-side setup:

  1. Register the dispatcher as an OIDC provider in AWS (the --url MUST equal the OIDC_ISSUER_URL Worker secret; AWS doesn’t allow updating it later):

    aws iam create-open-id-connect-provider \
      --url "https://<your-dispatcher>.workers.dev" \
      --client-id-list sts.amazonaws.com \
      --thumbprint-list <sha1-of-jwks-tls-leaf-cert>
  2. Create the IAM role, trust policy pinned to a Federated principal + a sub: pr-review:* pattern, with a narrow bedrock:InvokeModel policy:

    {
      "Effect": "Allow",
      "Principal": { "Federated": "arn:aws:iam::<acct>:oidc-provider/<your-dispatcher>.workers.dev" },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": { "<your-dispatcher>.workers.dev:aud": "sts.amazonaws.com" },
        "StringLike":   { "<your-dispatcher>.workers.dev:sub": "pr-review:*" }
      }
    }
  3. Set the Worker secrets + vars: OIDC_SIGNING_JWK (secret — pnpm cli oidc keygen), OIDC_ISSUER_URL (secret — must equal the OIDC provider --url), AI_GATEWAY_ID (var), CLOUDFLARE_ACCOUNT_ID (var), and optionally AI_GATEWAY_AUTH_TOKEN (secret — only if Authenticated Gateway is on). The matching public JWK is auto-served at <issuer>/.well-known/jwks.json.

  4. Point the backend at Bedrock: pr-review.backend=bedrock, pr-review.bedrock.model=bedrock/us.anthropic.claude-opus-4-6-v1, pr-review.bedrock.roleArn=<the role ARN> (and optionally pr-review.bedrock.region).

See 05-byoc § AWS federation trust policy for the full trust model.

One reviewer or many — pr-review.agents

The fan-out is configurable:

  • pr-review.agents=multi (default) — the tier-scaled per-domain personas above (1 / 4 / 7 reviewers by risk tier). The “multi-agent” reviewer.
  • pr-review.agents=single — one generalist reviewer covering every concern, through the same structured engine (same findings → annotations → verdict → consolidated comment). A leaner, cheaper pass; the historical multi-agent-review run collapsed into this mode.

The risk tier is still classified either way (it sizes the diff cap and renders in the comment); in single mode it just doesn’t scale the reviewer count.

Per-dispatch overrides — model bake-offs

When dispatched in Action mode (a GHA workflow POSTs the run), a single dispatch can override the CONFIG_KV defaults via the run inputs — agents, backend, modelId, region, roleArn, focusArea. Absent, the CONFIG_KV defaults apply (the Webhook path is unchanged). This is the model-bake-off / per-PR-escalation path: dispatch backend: "bedrock", a modelId under test, and a roleArn to compare a model on a real PR without touching CONFIG_KV. These inputs ride the HMAC-authenticated dispatch (operator-trusted), never the diff.

Output mode: tools vs json

Not every model honours tool-calling. Validated against Workers AI: a tool-calling-capable model (e.g. @cf/meta/llama-3.3-70b-…) returns tool calls fine, but a reasoning model (@cf/deepseek-ai/deepseek-r1-distill-qwen-32b) returns no tool calls (it emits <think>…</think> reasoning instead).

  • tools — sends the report tool and reads the Schema-validated tool args. Workers AI returns the args as a parsed object (the engine also tolerates an OpenAI-style JSON string). Best when the model supports it.
  • json — no tools; the model is asked for a strict JSON object. The engine strips <think>…</think> blocks and markdown code fences, isolates the JSON value, JSON.parses it, and Schema-decodes against the same Finding[] schema. A parse/decode failure surfaces a StructuredOutputInvalid error (the run posts a PR comment naming it).
  • Auto-fallback — a tools-mode call that comes back with zero tool calls retries once in json mode, so a model that silently drops tool-calling still produces a review.

Always a PR comment

Every run posts a top-level PR review comment (event: COMMENT) via the github capability — on success (the consolidated verdict + findings) and on failure (⚠️ pr-review could not complete: <reason>). The comment carries the <!-- flare-dispatch: pr-review --> footer marker.

Scheduled sweep — pr-review-sweep.run.ts

pr-review fires on every PR push (Webhook mode). That misses three cases: a PR opened before the App was installed, a webhook delivery GitHub dropped, and a PR whose review you want re-run on a cadence regardless of pushes. pr-review-sweep.run.ts closes them — a Schedule-mode run (04-gha-integration § Schedule mode) that fires on a Cloudflare Cron Trigger instead of a GitHub event.

flowchart LR
  CRON[Cron Trigger<br/>0 3 * * *] -->|scheduled| SW[pr-review-sweep<br/>scheduling Workflow]
  SW --> ENUM[enumerate<br/>github.openPullRequests]
  ENUM --> FAN[fan out · staggered<br/>one child per PR]
  FAN --> R1[pr-review · PR #41]
  FAN --> R2[pr-review · PR #58]
  FAN --> Rn[pr-review · PR #N]

The sweep contains no review logic — it reuses the pr-review run above, unchanged. It only decides what to review and when:

ConcernHow the sweep handles it
A cron tick names no targetThe enumerate step calls github.openPullRequests (03-dsl § github) — the App-token-backed read surface — to discover every open PR across the App’s installations.
Don’t re-review unchanged PRsEach child is created with the semantic instanceId pr-review:{repo}:{pr}:{headSha}. CF Workflows treats a duplicate create({ id }) as a no-op, so a PR already reviewed at its current head SHA — by Webhook mode or an earlier sweep — is skipped for free. The sweep is a backstop, not a duplicate channel.
Don’t burst the API / model providerThe fan-out is staggered with step.sleepUntil (03-dsl § Deferred scheduling) — children are spread evenly across a 45-minute window. The scheduling Workflow hibernates between slots, consuming no CPU.
Skip weekends / freeze windowsThe schedules[].gate receiver-side check skips the tick before any Workflow is created.

The cadence (0 3 * * *) lives in the run’s schedules and in wrangler.jsonc triggers.crons — the latter is what Cloudflare subscribes to (05-byoc § Wrangler config). The sweep posts no check-run of its own; each child pr-review posts its own per-PR check exactly as in Webhook mode.

Install

  1. Deploy FlareDispatch and install the GitHub App — specs/05-byoc.md.
  2. Copy pr-review.run.ts into your repo’s runs/ directory.
  3. Push. The Dispatcher auto-discovers the run; the next PR gets a flare-dispatch/pr-review check, with inline annotations on the Files-changed tab.

Opt a PR out with the skip-ai-review label; force review on a draft with request-ai-review.

Add the scheduled sweep (optional)

  1. Copy pr-review-sweep.run.ts into runs/ as well.
  2. Add its cron to wrangler.jsonc"triggers": { "crons": ["0 3 * * *"] } — and wrangler deploy. The expression must match the run’s schedules[].cron.
  3. At 03:00 UTC the Dispatcher’s scheduled() handler instantiates the sweep; every open PR not already reviewed at its head SHA gets a flare-dispatch/pr-review check.

Source

The recommended FlareDispatch shape is shown first. Toggle to Without FlareDispatch to see the full GitHub Actions workflow a team would maintain to do the same job without it.

Webhook mode — triggered directly by the FlareDispatch GitHub App webhook — zero GHA minutes.
recipes/ai-code-review/pr-review.run.ts
// Recipe: AI code review on every PR
//
// A FlareDispatch port of Cloudflare's multi-agent code reviewer —
// https://blog.cloudflare.com/ai-code-review/ — see ./README.md for how the
// blog's design maps onto this run.
//
// Drop this file into your repo's `runs/`; it is identical to the deployed
// `runs/pr-review.ts`. The review engine (`@flare-dispatch/review-agent`) runs
// in the Worker — no `review-agent` CLI in the container image.
//
// --- v3: the review runs IN THE WORKER, not a container CLI ------------------
//
// Earlier versions shelled out to a `review-agent` CLI baked into the run's
// container image. That CLI does not exist in the deployed image (every exec
// exited 127), so reviews silently failed. v3 moves the review into the run
// body via `@flare-dispatch/review-agent`, which calls a model through the
// `modelGateway` capability — backed by the Cloudflare Workers AI binding
// (`env.AI`) via an AI Gateway. The binding is the auth (Workers AI is
// account-billed), so NO model API key is configured. The ONE container image
// (infra/Dockerfile.sandbox: Node + git + curl) is used only for `git`
// (checkout + diff); every model call happens in the Worker, against a
// CONFIGURABLE backend resolved from CONFIG_KV.
//
// --- CONFIG the operator sets (out of band) ---------------------------------
//
//   CONFIG_KV  pr-review.agents         "single" (one generalist reviewer) | "multi" (tier-scaled per-domain personas)  (default "multi")
//   CONFIG_KV  pr-review.backend        "workers-ai" | "anthropic" | "bedrock"  (default workers-ai). A MODEL ROUTE, not an agentic tool — `opencode`/`reasonix` are reserved for the agent tier (specs/09-agentic-review.md), not this one.
//   CONFIG_KV  pr-review.prompt          (optional) REPLACE the reviewer system prompt
//   CONFIG_KV  pr-review.guidelines      (optional) ADDITIVE house rules appended to the reviewer prompt (suppression rubric / conventions / severity calibration)
//   CONFIG_KV  pr-review.workers-ai.model  model id — a bare Workers AI catalog id (e.g. @cf/meta/llama-3.3-70b-instruct-fp8-fast, account-billed, no key) OR a `deepseek/`-prefixed hosted reasoner (e.g. deepseek/deepseek-reasoner, BYOK via AI Gateway — the real model)
//   CONFIG_KV  pr-review.workers-ai.mode   "tools" | "json"  (default "tools"; pin "json" for reasoning models — DeepSeek-class models ignore tool-calls)
//   CONFIG_KV  pr-review.<backend>.maxDiffChars  (optional) override the per-backend diff cap — a positive int clamped to [1_000, 1_000_000]. Defaults: workers-ai 60_000, anthropic/bedrock 240_000. Raise it for a big-context Workers AI model (GLM / Kimi); a value above the model's context overflows invisibly.
//   CONFIG_KV  pr-review.<backend>.maxTokens  (optional) override the per-backend output-token budget — a positive int clamped to [256, 32_768]. Defaults: workers-ai 8_192, anthropic/bedrock 4_096. A ceiling (non-reasoning models unaffected); raise it if a reasoning model truncates inside <think> before the JSON answer.
//   CONFIG_KV  pr-review.anthropic.model `anthropic/`-prefixed model id (e.g. anthropic/claude-sonnet-4-6) — BYOK via AI Gateway
//   CONFIG_KV  pr-review.anthropic.mode  "tools" | "json"  (default "tools")
//   CONFIG_KV  pr-review.bedrock.model   `bedrock/`-prefixed model id (e.g. bedrock/us.anthropic.claude-opus-4-6-v1) — BYOC via AI Gateway
//   CONFIG_KV  pr-review.bedrock.region  AWS region (default us-east-1)
//   CONFIG_KV  pr-review.bedrock.roleArn IAM role to AssumeRoleWithWebIdentity into — trust policy MUST pin `sub: pr-review:*`
//   CONFIG_KV  pr-review.style           "default" (verbose verdict-table) | "compact" (LGTM-header + 3-col emoji table)
//   CONFIG_KV  pr-review.compact-max     how many findings the `compact` layout lists inline before "…and N more" (positive int, clamp 1..100, default 7; no-op for `default`)
//
// No API key: the Workers AI binding is the auth. A "tools"-mode backend that
// returns no tool calls auto-retries once in "json" mode, so a model that
// silently drops tool-calling still produces a review. A json-mode answer that
// carries no parseable JSON (prose, or a value truncated inside a reasoning
// block) gets ONE blunt "JSON only" repair retry before it counts as failed.
//
// The per-domain fan-out is fault-ISOLATED: one reviewer whose model call fails
// (unparseable output, a transient 429) is dropped to zero findings and flagged
// in the engagement line — the review still ships with the domains that
// succeeded. The run only goes red when EVERY reviewer fails.
//
// --- Per-dispatch overrides (Action mode) -----------------------------------
//
// A dispatch MAY override the CONFIG_KV defaults per call via the run inputs —
// `agents`, `backend`, `modelId`, `region`, `roleArn`, `focusArea`. Absent, the
// CONFIG_KV defaults apply, so the everyday Webhook path is unchanged. This is
// the model-bake-off / per-PR-escalation path the former `multi-agent-review`
// run served: dispatch `backend: "bedrock"`, a `modelId` under test, and a
// `roleArn` to compare a model on a real PR without touching CONFIG_KV.
//
// Mode: Webhook mode (fires on every pull_request push, zero GHA minutes) AND
//       Action mode (a GHA workflow dispatches it with per-call overrides).
// DSL:  see specs/03-dsl.md (uses `config` + `github`).

import { Effect, Schema, Match, Option, Either } from "effect";
import {
  defineRun,
  step,
  sandbox,
  artifact,
  config,
  io,
  github,
  type ReadFileFailed,
  StepFailed,
  type Container,
  type WebhookPayload,
} from "@flare-dispatch/core";
import { awsAssumeRole, workspace } from "@flare-dispatch/core/primitives";
import {
  type BackendUnconfigured,
  backendConfigKey,
  capDiff,
  composeSystemPrompt,
  coordinate as engineCoordinate,
  DEFAULT_NAMESPACE,
  DEFAULT_REVIEW_SYSTEM_PROMPT,
  type Finding,
  guidelinesKey,
  type ModelCallFailed,
  namespacedKeys,
  parseBackend,
  resolveBackend,
  reviewDomain,
  riskTier,
  ReviewOutputSchema,
  stripDiffNoise,
  type StructuredOutputInvalid,
  type Tier,
} from "@flare-dispatch/review-agent";

// Local helper — true if the PR carries the given label.
const hasLabel = (payload: WebhookPayload, name: string): boolean =>
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
  payload.pull_request?.labels?.some((l: { name: string }) => l.name === name) ?? false;

// The domain-scoped reviewers, one per concern (blog: "up to seven
// domain-specific agents"). The risk tier selects which subset actually runs.
const FULL_AGENTS = [
  "security",
  "performance",
  "code-quality",
  "documentation",
  "release-management",
  "compliance",
  "agents-md",
] as const;
const LITE_AGENTS = ["security", "code-quality", "performance", "documentation"] as const;
const TRIVIAL_AGENTS = ["code-quality"] as const;

// Agent fan-out mode. `multi` (the default) fans out to the tier-scaled
// per-domain personas above — the historical behaviour. `single` runs ONE
// generalist reviewer covering every concern (the collapsed `multi-agent-review`
// run's single-agent shape, now through the same structured engine). The
// operator picks the mode via `pr-review.agents` CONFIG_KV; a dispatch overrides
// it per call via the `agents` input.
const GENERAL_AGENT = ["general"] as const;
const AGENT_MODES = ["single", "multi"] as const;
type AgentMode = (typeof AGENT_MODES)[number];
const DEFAULT_AGENT_MODE: AgentMode = "multi";

/** Narrow an arbitrary config string to a known agent mode, or the default. */
const parseAgentMode = (raw: string | undefined): AgentMode =>
  AGENT_MODES.includes(raw as AgentMode) ? (raw as AgentMode) : DEFAULT_AGENT_MODE;

/** Config namespace this run's backend + prompt keys live under (`pr-review.*`). */
const NS = DEFAULT_NAMESPACE;

// The run's output. `findings` becomes the check-run annotation set; the rest
// renders in the summary. Imported from the engine package so the run's
// `outputs` schema and the engine's return type are one source of truth. Each
// push re-reviews the full PR diff independently — the current run is
// authoritative (no carry-over from prior executions), so a fixed finding clears.
const ReviewOutput = ReviewOutputSchema;

/** Footer marker on every PR comment this run posts — for idempotent updates. */
const COMMENT_MARKER = "<!-- flare-dispatch: pr-review -->";

/**
 * The "view full logs" footer for a PR comment — a markdown link to this
 * execution's log viewer, which (since #137) also lists the reviewed
 * `pr-review.diff` artifact. `undefined` viewer URL (a deploy with no public
 * origin / no log-link key) → empty string, so the comment renders exactly as
 * before. `viewerUrl` is dispatcher-minted (tokened), never model-authored, so
 * it needs no sanitization.
 */
const viewerFooter = (viewerUrl: string | undefined): readonly string[] =>
  viewerUrl === undefined
    ? []
    : ["", `📋 [View full logs & reviewed diff ↗](${viewerUrl})`];

/**
 * Where `prepare-diff` writes the unified diff inside the container. Read back
 * in full via `sandbox.readFile` — `ExecResult.stdout` inlines only a 16KB
 * tail, which silently reviewed a sliver of any sizeable PR.
 */
const DIFF_FILE = "/tmp/pr-review.diff";

// --- Oxc grounding -----------------------------------------------------------
//
// Before the model fans out, run oxlint (the Oxc Rust linter — Vite/VoidZero,
// now inside Cloudflare) on the PR's changed files IN THE SAME container the
// diff was produced in, and prepend its findings to the reviewable text. The
// model then CONFIRMS / EXPANDS deterministic, pre-computed static-analysis
// results (with exact `file:line` anchors) instead of re-deriving lint-level
// issues from the diff alone — cheaper tokens, fewer hallucinated findings,
// citable anchors.
//
// Best-effort by construction: oxlint runs via `npx` (no image change), and ANY
// failure — no lintable files, fetch error, read error — degrades to an empty
// block, so the review proceeds ungrounded and never goes red on this step.

/** Where the oxlint run writes its findings inside the container. */
const OXLINT_FILE = "/tmp/pr-review.oxlint.txt";
/** oxlint version line fetched via `npx` — tracks the 1.x major. */
const OXLINT_VERSION = "1";
/** Cap the changed-file list passed to oxlint (bounds the command line). */
const OXLINT_MAX_FILES = 60;
/** Cap the grounding block prepended to the model context. */
const OXLINT_MAX_CHARS = 8000;
/** Extensions oxlint lints — the changed files worth scanning. */
const LINTABLE_EXT = /\.(?:m?[jt]sx?|cjs)$/;

/**
 * Pull the changed (added/modified) file paths from a unified diff's
 * `+++ b/<path>` headers, keep the oxlint-lintable ones, dedupe, and cap.
 * `/dev/null` targets (deletions) are dropped.
 */
const changedLintableFiles = (diff: string): readonly string[] => {
  const files = new Set<string>();
  for (const line of diff.split("\n")) {
    if (!line.startsWith("+++ ")) continue;
    const target = line.slice(4).trim();
    if (target === "/dev/null") continue;
    const path = target.startsWith("b/") ? target.slice(2) : target;
    if (LINTABLE_EXT.test(path)) files.add(path);
  }
  return [...files].slice(0, OXLINT_MAX_FILES);
};

/** Wrap raw oxlint output in a labelled, capped grounding block (or "" if empty). */
const groundingBlock = (raw: string): string => {
  const trimmed = raw.trim();
  if (trimmed.length === 0) return "";
  const body =
    trimmed.length > OXLINT_MAX_CHARS
      ? `${trimmed.slice(0, OXLINT_MAX_CHARS)}\n…(truncated)`
      : trimmed;
  return [
    "## Static analysis — oxlint findings on the changed files",
    "",
    "Deterministic, pre-computed by the Oxc linter. Treat as authoritative:",
    "confirm/expand these, cite their `file:line` anchors, and do NOT re-derive",
    "lint-level issues the linter already reports.",
    "",
    "```",
    body,
    "```",
  ].join("\n");
};

export const prReview = defineRun({
  name: "pr-review",
  version: "3.1.0",
  image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",

  triggers: [
    {
      event: "pull_request",
      actions: ["opened", "synchronize", "ready_for_review"],
      // MUST match Action mode's semantic instanceId — `{run}:{repo_}:{sha12}`
      // (dispatch.ts `semanticInstanceId`) — so a repo that BOTH installs the
      // App (webhook mode) and dispatches from its own CI (Action mode)
      // collapses to ONE review per head SHA at the `create({id})` layer
      // instead of paying two sandbox runs. The PR number adds nothing to the
      // identity: the dispatcher's dedup contract is "same {run, repo, sha} →
      // same execution", and the number still rides in `inputs`.
      idempotencyKey: ({ payload }) =>
        `pr-review:${payload.repository.full_name.replace(/\//g, "_")}:${payload.pull_request.head.sha.slice(0, 12)}`,
      gate: ({ payload }) =>
        (!payload.pull_request.draft || hasLabel(payload, "request-ai-review")) &&
        !hasLabel(payload, "skip-ai-review") &&
        !payload.pull_request.user.login.endsWith("[bot]"),
      inputs: ({ payload }) => ({
        repo: payload.repository.full_name,
        sha: payload.pull_request.head.sha,
        baseSha: payload.pull_request.base.sha,
        pr: payload.pull_request.number,
        installationId: payload.installation.id,
      }),
    },
  ],

  inputs: Schema.Struct({
    repo: Schema.String,
    sha: Schema.String,
    baseSha: Schema.String,
    pr: Schema.Number,
    // Webhook mode maps it from `payload.installation.id`; Action mode omits
    // it. The run threads it to `github.pullReview` to authenticate the comment.
    installationId: Schema.optional(Schema.Number),
    // --- Per-dispatch overrides (Action mode) — all optional. When absent the
    //     CONFIG_KV defaults apply, so the Webhook path is unchanged. These ride
    //     the HMAC-authenticated dispatch (operator-trusted), never the diff. ---
    /** Override `pr-review.agents` — one generalist reviewer vs the persona fan-out. */
    agents: Schema.optional(Schema.Literal("single", "multi")),
    /** Override `pr-review.backend` — pin a backend for this dispatch (e.g. a bake-off). */
    backend: Schema.optional(Schema.String),
    /** Override the resolved backend's model id (e.g. the model under test). */
    modelId: Schema.optional(Schema.String),
    /** Override the bedrock backend's AWS region. */
    region: Schema.optional(Schema.String),
    /** Override the bedrock backend's IAM role ARN to AssumeRoleWithWebIdentity into. */
    roleArn: Schema.optional(Schema.String),
    /** Extra focus line appended to the reviewer system prompt for this dispatch. */
    focusArea: Schema.optional(Schema.String),
  }),

  outputs: ReviewOutput,

  limits: { maxDurationSec: 1500, maxConcurrency: FULL_AGENTS.length },

  // At most one review per PR per 30 minutes, across BOTH dispatch paths
  // (webhook + Action). A rapid push sequence on an active PR collapses to
  // the first dispatch of the window; the skipped pushes answer 202 with the
  // prior execution's id, so CI stays green. The LAST state of a busy PR
  // still gets reviewed on its next dispatch after the window — and a review
  // can always be forced by re-running the CI job ≥30 min later.
  cooldown: { seconds: 1800, scope: (input) => `pr-${input.pr}` },

  run: (input) =>
    Effect.gen(function* () {
      // This execution's log-viewer URL — links every PR comment (success OR
      // failure) back to the full logs + the reviewed `pr-review.diff` artifact.
      // `Option.none()` on a deploy with no public origin / log-link key, in
      // which case the comment renders link-less, as before.
      const viewerUrl = Option.getOrUndefined(yield* io.viewerUrl);
      // The whole review is wrapped in an error boundary that ALWAYS posts a PR
      // comment — success or failure. `reviewBody` produces the output; the
      // catch arm posts a "could not complete" comment and re-fails (as a
      // `StepFailed`, a member of `RunError`) so the check still goes red
      // honestly. The comment post itself is best-effort — a failure to post
      // must not mask the original cause.
      return yield* reviewBody(input, viewerUrl).pipe(
        Effect.catchAll((err) =>
          Effect.gen(function* () {
            const reason = describeError(err);
            // Post inside a step so a CF Workflow instance retry replays from
            // the checkpoint instead of posting a duplicate failure comment.
            yield* step("post-failure-comment", () =>
              postComment(
                input,
                [
                  `⚠️ **pr-review could not complete**: ${reason}`,
                  ...viewerFooter(viewerUrl),
                  "",
                  COMMENT_MARKER,
                ].join("\n"),
              ).pipe(
                Effect.catchAll((postErr) =>
                  io.log(
                    "warn",
                    `pr-review: failure-comment post failed — ${describeError(postErr)}`,
                  ),
                ),
              ),
            );
            return yield* Effect.fail(
              new StepFailed({ step: "pr-review", cause: reason }),
            );
          }),
        ),
      );
    }),
});

// ---------------------------------------------------------------------------
// The review proper.

const reviewBody = (input: RunInput, viewerUrl?: string) =>
  Effect.gen(function* () {
    // 1. Resolve the configurable backend (model id + output mode + diff cap)
    //    from CONFIG_KV, with any per-dispatch input overrides layered on top —
    //    FIRST, before paying for a container, so a misconfigured backend fails
    //    fast → the error boundary posts a PR comment naming the missing key. No
    //    API key — the model is called through the `modelGateway` capability
    //    (Workers AI binding via an AI Gateway), which the runtime provides
    //    ambiently.
    const resolved = yield* step("resolve-backend", () =>
      resolveEffectiveBackend(input),
    );

    // 2. Check out the PR head. `git` is in the image; no dependency install.
    const { container, dir: repoDir } = yield* step("checkout", () =>
      workspace({ repo: input.repo, sha: input.sha }),
    );

    // 3. Build the reviewable diff with plain `git` (no `review-agent` CLI).
    //    A non-zero exit FAILS the step (honest red check) — see `execOrFail`.
    //
    //    The diff is written to a FILE and read back with `sandbox.readFile`
    //    — NOT taken from `ExecResult.stdout`, which inlines only a bounded
    //    16KB tail (the rest streams to R2). Reading stdout silently reviewed
    //    just the last sliver of any sizeable PR: the risk tier under-counted
    //    (a huge PR classified `lite`/`trivial`), sensitive paths escaped the
    //    `full` escalation, and the reviewers "found nothing" because they
    //    never saw the change.
    //
    //    Noise-strip + backend-sized cap happen INSIDE the step so the value
    //    the Workflow checkpoints is bounded by `maxDiffChars`, never the raw
    //    multi-MB diff. One global cap sized for a frontier model would
    //    overflow a catalog model's context invisibly (the model goes
    //    needle-blind and "finds nothing") — hence the RESOLVED backend's cap.
    //
    //    THREE-dot diff (`base...head`), never two-dot: `baseSha` is the base
    //    branch TIP at event time (`pull_request.base.sha`), not the fork
    //    point. A two-dot endpoint diff on any PR behind its base reviewed
    //    `base-tip → head`, presenting everything merged to the base since
    //    the fork as DELETIONS — reviewers flagged phantom removals of files
    //    the PR never touched. Three-dot diffs from `merge-base(base, head)`,
    //    matching the PR diff GitHub itself renders.
    const diff = yield* step("prepare-diff", () =>
      execOrFail({
        container,
        cwd: repoDir,
        command: [
          "git",
          "diff",
          "--unified=3",
          `--output=${DIFF_FILE}`,
          `${input.baseSha}...${input.sha}`,
        ],
      }).pipe(
        Effect.andThen(sandbox.readFile({ container, path: DIFF_FILE })),
        Effect.map((raw) => capDiff(stripDiffNoise(raw), resolved.maxDiffChars)),
      ),
    );

    // 3b. Persist the FULL (uncapped) diff as an artifact so the log viewer can
    //     surface it and a reviewer can read exactly what was reviewed. The
    //     checkpointed `diff` value is capped for the model's context; the file
    //     on disk is the whole thing. Best-effort: a `git diff --output=<file>`
    //     emits no stdout, so without this the run leaves no visible record of
    //     the diff. A capture failure must never fail the review — swallow it.
    yield* step("upload-diff", () =>
      artifact.upload({
        name: "pr-review.diff",
        path: DIFF_FILE,
        container,
        contentType: "text/plain; charset=utf-8",
      }),
    ).pipe(
      Effect.catchAllCause((cause) =>
        Effect.logWarning(`upload-diff failed (non-fatal): ${cause}`),
      ),
    );

    // 4. Risk tier — a pure heuristic on diff size + touched paths (no model
    //    call). The tier is always classified (it sizes the diff cap + renders
    //    in the comment); the agent MODE decides whether it also scales the
    //    persona fan-out.
    const tier = yield* step("classify-risk", () => riskTier({ diff }));

    // 4b. Agent fan-out mode — `single` (one generalist reviewer) vs `multi`
    //     (the tier-scaled per-domain personas). Input override > CONFIG_KV >
    //     default. The plan's agent set follows from the mode.
    const agentMode: AgentMode =
      input.agents ??
      parseAgentMode(
        yield* step("resolve-agents", () => config.get("pr-review.agents")),
      );
    const plan = planForMode(agentMode, tier);

    // 5. The reviewer system prompt — layered base → guidelines → focus,
    //    composed here (not threaded through the engine) so `focusArea` rides
    //    the trusted dispatch input, never the attacker-controllable diff. The
    //    model's OUTPUT is still sanitized before it renders in the public
    //    comment regardless.
    //      - `pr-review.prompt`     REPLACES the base reviewer instruction.
    //      - `pr-review.guidelines` is ADDITIVE — appended on top as authoritative
    //        house rules (a suppression rubric, project conventions, severity
    //        calibration), so an operator can shape the review without forking
    //        the maintained default prompt.
    const promptOverride = yield* step("resolve-prompt", () =>
      config.get("pr-review.prompt"),
    );
    const guidelines = yield* step("resolve-guidelines", () =>
      config.get(guidelinesKey(NS)),
    );
    const systemPrompt = composeSystemPrompt({
      base: promptOverride ?? DEFAULT_REVIEW_SYSTEM_PROMPT,
      ...(guidelines !== undefined ? { guidelines } : {}),
      ...(input.focusArea !== undefined ? { focus: input.focusArea } : {}),
    });

    // 5b. Comment style preset — operator picks the comment layout. Both
    //     presets are hard-coded server-side (so model-authored text can't
    //     inject layout). See `parseStyle` + `renderReviewComment`.
    const style = parseStyle(
      yield* step("resolve-style", () => config.get("pr-review.style")),
    );

    // 5b-ii. How many findings the `compact` layout lists inline before the
    //        "…and N more" overflow line. Operator-tunable so the leaderboard
    //        layout can show more (or fewer) without a code change; clamps to a
    //        sane range, defaults to 7. No-op for the `default` layout, which
    //        uses its own MAX_RENDERED_FINDINGS.
    const compactMax = parseCompactMax(
      yield* step("resolve-compact-max", () => config.get("pr-review.compact-max")),
    );

    // 5c. When the resolved backend is `bedrock`, mint short-lived AWS creds via
    //     OIDC federation — the modelGateway Bedrock route SigV4-signs each call
    //     with these. The role's trust policy SHOULD pin `sub: pr-review:*` so
    //     a leaked HMAC alone can't assume the role; `awsAssumeRole`'s default
    //     subject is `<run>:<execution-id>`, which matches that pattern. Other
    //     backends skip this step entirely.
    const bedrockCreds =
      resolved.backend === "bedrock" &&
      resolved.roleArn !== undefined &&
      resolved.region !== undefined
        ? yield* step("assume-bedrock-role", () =>
            awsAssumeRole({
              roleArn: resolved.roleArn as string,
              region: resolved.region as string,
              sessionName: `pr-review-${input.sha.slice(0, 12)}`,
            }),
          )
        : undefined;
    const awsCreds =
      bedrockCreds !== undefined && resolved.region !== undefined
        ? {
            accessKeyId: bedrockCreds.accessKeyId,
            secretAccessKey: bedrockCreds.secretAccessKey,
            sessionToken: bedrockCreds.sessionToken,
            region: resolved.region,
          }
        : undefined;

    // 5d. Oxc grounding — run oxlint on the PR's changed files in the checkout
    //     container and prepend its findings to the reviewable text. Best-
    //     effort: any failure (no lintable files, npx fetch, read) degrades to
    //     the ungrounded diff via `catchAllCause`, so the review never goes red
    //     on this step. `riskTier` above used the RAW diff (size heuristic), so
    //     the grounding block doesn't perturb tier classification.
    const oxlintBlock = yield* step("oxlint-scan", () =>
      Effect.gen(function* () {
        const files = changedLintableFiles(diff);
        if (files.length === 0) return "";
        // Non-zero exit (oxlint found issues) is a NORMAL ExecResult, not a
        // failure; redirect both streams to a file read back in full (the
        // 16KB stdout tail would truncate a busy lint report).
        yield* sandbox.exec({
          container,
          cwd: repoDir,
          command: `npx --yes oxlint@${OXLINT_VERSION} ${files.join(" ")} > ${OXLINT_FILE} 2>&1`,
        });
        const out = yield* sandbox.readFile({ container, path: OXLINT_FILE });
        return groundingBlock(out);
      }),
    ).pipe(Effect.catchAllCause(() => Effect.succeed("")));
    const groundedDiff =
      oxlintBlock.length > 0 ? `${oxlintBlock}\n\n${diff}` : diff;

    // 6. Fan out one reviewer per domain, IN-WORKER, in parallel — only the
    //    agents this tier calls for. Each calls the model via the `modelGateway`
    //    capability (provided by the runtime, like `config`/`sandbox`); findings
    //    are Schema-validated tool-call / json output. The reviewers see the
    //    GROUNDED diff (oxlint findings prepended), not the raw diff.
    //
    //    FAULT-ISOLATED fan-out: each reviewer is wrapped in `Effect.either`, so
    //    one domain whose model call fails — unparseable output
    //    (`StructuredOutputInvalid`), a transient 429 under the concurrent
    //    fan-out (`ModelCallFailed`) — is dropped to zero findings and flagged
    //    `errored` in the engagement line, rather than aborting the whole review.
    //    The review still ships with every domain that DID succeed — resilience
    //    is the point of the multi-agent design. ONLY when EVERY reviewer fails
    //    do we re-raise (the typed cause), so the boundary posts an honest "could
    //    not complete" for a systemic fault (misconfigured backend, wrong
    //    model/mode, gateway down) instead of masking it as an empty review.
    const reviewed = yield* step("review", () =>
      Effect.gen(function* () {
        const results = yield* Effect.forEach(
          plan.agents,
          (agent) =>
            reviewDomain({
              agent,
              diff: groundedDiff,
              tier: plan.tier,
              model: resolved.model,
              backend: resolved.backend,
              mode: resolved.mode,
              maxTokens: resolved.maxTokens,
              systemPrompt,
              ...(awsCreds !== undefined ? { aws: awsCreds } : {}),
            }).pipe(Effect.either),
          { concurrency: plan.agents.length },
        );

        // Log each failed domain's cause (the per-reviewer error is otherwise
        // swallowed by the tolerance below) for operator diagnosis in the logs.
        for (let i = 0; i < plan.agents.length; i++) {
          const r = results[i];
          if (r !== undefined && Either.isLeft(r)) {
            yield* io.log(
              "warn",
              `pr-review: reviewer "${plan.agents[i]}" failed — ${describeError(r.left)}`,
            );
          }
        }

        // Every reviewer failed → no partial review to salvage; re-raise the
        // first cause (typed) so the error boundary names it precisely.
        const firstLeft = results.find(Either.isLeft);
        if (firstLeft !== undefined && results.every(Either.isLeft)) {
          return yield* Effect.fail(firstLeft.left);
        }

        // Findings from the domains that succeeded; failed domains contribute
        // none. Per-domain counts (or an `errored` flag) render in the comment so
        // an all-empty review is visibly "N reviewers each reported 0", and a
        // degraded review is visibly "this domain errored" — never silently
        // indistinguishable from "found nothing".
        const findings: ReadonlyArray<Finding> = results.flatMap((r) =>
          Either.isRight(r) ? r.right : [],
        );
        const domainCounts: ReadonlyArray<DomainCount> = plan.agents.map(
          (agent, i) => {
            const r = results[i];
            return r !== undefined && Either.isRight(r)
              ? { agent, count: r.right.length }
              : { agent, count: 0, errored: true };
          },
        );
        return { findings, domainCounts };
      }),
      // Cap the per-step retry. CF Workflows' default is `limit: 5` (up to 6
      // attempts), and a step retry REPLAYS the whole fan-out. This step throws
      // ONLY when EVERY reviewer failed — an all-fail is systemic: a 429 storm
      // from the concurrent burst (`concurrency: plan.agents.length`) hitting a
      // low-per-model-rate-limit model, a backend down, or a wrong model/mode.
      // The per-domain calls are NOT individually retried on 429, so replaying
      // the 7-way burst 5 more times just re-throttles the same model and
      // re-issues every call. One retry (after the backoff, by which the
      // per-minute rate window may have rolled over) keeps recovery for a
      // genuine transient while bounding the worst case from ×6 attempts to ×2.
      { retries: 1 },
    );
    const allFindings: ReadonlyArray<Finding> = reviewed.findings;
    const domainCounts: ReadonlyArray<DomainCount> = reviewed.domainCounts;

    // 7. Coordinate — PURE deterministic assembly (dedup + counts + verdict) over
    //    THIS run's findings. No model call; the current run is authoritative, so
    //    a fixed finding clears. `tier` is stitched in from the plan.
    const coordinated = yield* step("coordinate", () =>
      engineCoordinate({ findings: allFindings }),
    );
    const output = { ...coordinated, tier: plan.tier };

    // 8. Post the visible top-level PR review comment (the findings additionally
    //    land as check-run annotations via the run output). Best-effort — a
    //    comment failure must not turn a green review red.
    yield* step("post-comment", () =>
      postComment(
        input,
        renderReviewComment(input, output, domainCounts, style, viewerUrl, compactMax),
      ).pipe(
        Effect.catchAll((e) =>
          io.log("warn", `pr-review: posting PR comment failed — ${describeError(e)}`),
        ),
      ),
    );

    return output;
  });

// ---------------------------------------------------------------------------
// Helpers.

type RunInput = {
  readonly repo: string;
  readonly sha: string;
  readonly baseSha: string;
  readonly pr: number;
  readonly installationId?: number;
  // Per-dispatch overrides (Action mode) — see the `inputs` schema.
  readonly agents?: AgentMode;
  readonly backend?: string;
  readonly modelId?: string;
  readonly region?: string;
  readonly roleArn?: string;
  readonly focusArea?: string;
};

type Plan = {
  readonly tier: Tier;
  readonly agents: readonly string[];
};

/** Map the risk tier to its agent set. (The model is resolved from the backend
 *  config, not the tier — see `resolveBackend`.) */
const planForTier = (tier: Tier): Plan =>
  Match.value(tier).pipe(
    Match.when("trivial", () => ({ tier: "trivial" as const, agents: TRIVIAL_AGENTS })),
    Match.when("lite", () => ({ tier: "lite" as const, agents: LITE_AGENTS })),
    Match.when("full", () => ({ tier: "full" as const, agents: FULL_AGENTS })),
    Match.exhaustive,
  );

/** Map the resolved agent mode + risk tier to the reviewer plan. `single` runs
 *  ONE generalist reviewer (the tier still renders in the comment); `multi` runs
 *  the tier-scaled per-domain personas. */
const planForMode = (mode: AgentMode, tier: Tier): Plan =>
  mode === "single" ? { tier, agents: GENERAL_AGENT } : planForTier(tier);

/**
 * Resolve the active backend, layering per-dispatch input overrides over
 * CONFIG_KV. An override-aware `getConfig` shim feeds the engine's
 * `resolveBackend` the input values where present — so a single dispatch can
 * fully specify a backend (e.g. a Bedrock model bake-off: `backend: "bedrock"`,
 * `modelId`, `roleArn`, `region`) with NO CONFIG_KV keys set, while an everyday
 * webhook review with no overrides resolves exactly as before. The shim only
 * shadows the keys an override targets; everything else still reads CONFIG_KV.
 */
const resolveEffectiveBackend = (input: RunInput) =>
  Effect.gen(function* () {
    // The effective backend names which `<backend>.*` keys an override targets.
    const backend = parseBackend(
      input.backend ?? (yield* config.get(backendConfigKey(NS))),
    );
    const keys = namespacedKeys(NS)[backend];
    const overrides = new Map<string, string>();
    if (input.backend !== undefined)
      overrides.set(backendConfigKey(NS), input.backend);
    if (input.modelId !== undefined) overrides.set(keys.modelKey, input.modelId);
    if (input.region !== undefined && keys.regionKey !== undefined)
      overrides.set(keys.regionKey, input.region);
    if (input.roleArn !== undefined && keys.roleArnKey !== undefined)
      overrides.set(keys.roleArnKey, input.roleArn);
    return yield* resolveBackend(
      (key) =>
        overrides.has(key)
          ? Effect.succeed<string | undefined>(overrides.get(key))
          : config.get(key),
      { namespace: NS },
    );
  });

/**
 * Run a container command and FAIL the Effect when it exits non-zero. The core
 * `sandbox.exec` deliberately surfaces a non-zero exit as a normal `ExecResult`
 * (a failing test is data, not an error) — but for `pr-review`'s git steps a
 * non-zero exit IS a real failure that must turn the check red, so we lift it
 * into the typed error channel here (rather than mutating the shared
 * `sandbox.exec`, which other runs rely on).
 */
const execOrFail = (opts: {
  container: Container;
  cwd: string;
  command: readonly string[];
  timeoutSec?: number;
}) =>
  sandbox.exec(opts).pipe(
    Effect.flatMap((result) =>
      result.exitCode === 0
        ? Effect.succeed(result)
        : Effect.fail(
            new ExecNonZero({
              command: opts.command.join(" "),
              exitCode: result.exitCode,
              stderrTail: result.stderr.slice(-2000),
            }),
          ),
    ),
  );

/** A container command that ran to completion but exited non-zero. */
class ExecNonZero extends Schema.TaggedError<ExecNonZero>()("ExecNonZero", {
  command: Schema.String,
  exitCode: Schema.Number,
  stderrTail: Schema.String,
}) {}

/** Post a top-level PR review comment via the `github` capability. */
const postComment = (input: RunInput, body: string) =>
  github.pullReview({
    repo: input.repo,
    pr: input.pr,
    sha: input.sha,
    body,
    ...(input.installationId !== undefined
      ? { installationId: input.installationId }
      : {}),
  });

/** The tagged errors the review boundary knows how to describe precisely;
 *  anything else (e.g. `StepFailed`, a core capability error) falls to the
 *  `Match.orElse` arm. */
type DescribableError =
  | BackendUnconfigured
  | ModelCallFailed
  | StructuredOutputInvalid
  | ExecNonZero
  | ReadFileFailed;

/** Human-readable one-liner for any error the boundary catches. */
const describeError = (err: unknown): string =>
  Match.value(err as DescribableError).pipe(
    Match.tag(
      "BackendUnconfigured",
      (e) => `backend "${e.backend}" is misconfigured — set ${e.missing}`,
    ),
    Match.tag(
      "ModelCallFailed",
      (e) => `model call failed (${e.reason}): ${e.message}`,
    ),
    Match.tag(
      "StructuredOutputInvalid",
      (e) =>
        `model returned unparseable ${e.surface} output (${e.reason}); the backend may need \`mode: "json"\` or a different model` +
        // Surface a short, SANITIZED excerpt of the RAW model text. It is
        // captured on the error but was previously dropped here, so the only
        // record of what the model actually returned lived in the (auth-gated)
        // step logs — which is exactly why an `empty` (the model's answer
        // silently dropped at the binding boundary) took so long to diagnose.
        // `sanitizeModelText` defangs it; the verdict never derives from it.
        (e.excerpt.trim() !== ""
          ? ` — model returned: "${sanitizeModelText(e.excerpt)}"`
          : ""),
    ),
    Match.tag(
      "ExecNonZero",
      (e) => `\`${e.command}\` exited ${e.exitCode}`,
    ),
    Match.tag(
      "ReadFileFailed",
      (e) => `reading the diff file \`${e.path}\` failed: ${e.message}`,
    ),
    Match.orElse(() =>
      err instanceof Error ? err.message : JSON.stringify(err),
    ),
  );

/**
 * Neutralize model-authored text before it renders in the public PR comment.
 * The diff is attacker-controllable on a hostile PR and feeds the model, so a
 * finding's `title`/`message`/`path` could carry `@mention` pings, raw HTML, or
 * code-fence/backtick break-outs. Collapse to one line, drop angle brackets,
 * defang `@` and backticks, and bound the length. (Control flow is already safe
 * — the verdict derives only from the schema-constrained `level`.)
 */
const SANITIZE_MAX = 500;
// U+200B zero-width space — inserted after `@` it breaks GitHub's @mention
// autolink without visibly altering the text. Built from a code point so the
// source stays ASCII-only.
const ZWSP = String.fromCharCode(0x200b);
const sanitizeModelText = (s: string): string =>
  s
    .replace(/[\r\n]+/g, " ")
    .replace(/[<>]/g, "")
    .replace(/`/g, "'")
    .replace(/@(?=[\w-])/g, `@${ZWSP}`)
    .slice(0, SANITIZE_MAX);

/** One domain reviewer's engagement — how many findings it reported, or
 *  `errored: true` when its model call failed and it was skipped (count 0). */
type DomainCount = {
  readonly agent: string;
  readonly count: number;
  readonly errored?: boolean;
};

/** How many findings render in the comment; the rest land as check annotations. */
const MAX_RENDERED_FINDINGS = 25;

/** Severity → icon + label, shared by the summary table and per-finding headings.
 *  Labels mirror the header's count names (critical / warnings / suggestions). */
const severityBadge = (level: Finding["level"]): string =>
  Match.value(level).pipe(
    Match.when("failure", () => "🛑 Critical"),
    Match.when("warning", () => "⚠️ Warning"),
    Match.when("notice", () => "💡 Suggestion"),
    Match.exhaustive,
  );

/**
 * GitHub blob URL for a finding — `https://github.com/<repo>/blob/<sha>/<path>#L<n>`.
 * `repo`/`sha` come from the trusted webhook input; `path` is model-authored, so
 * each segment is sanitized then URL-encoded (plus manual paren-encoding —
 * `encodeURIComponent` leaves `()` alone, and a bare `)` would terminate the
 * markdown link). The line fragment is dropped when the model's line numbers
 * are nonsense (≤ 0), leaving a plain file link.
 */
const findingUrl = (repo: string, sha: string, f: Finding): string => {
  const encodedPath = sanitizeModelText(f.path)
    .replace(/^\/+/, "")
    .split("/")
    .map(encodeURIComponent)
    .join("/")
    .replace(/\(/g, "%28")
    .replace(/\)/g, "%29");
  const start = Math.floor(f.startLine);
  const end = Math.floor(f.endLine);
  const fragment = start > 0 ? (end > start ? `#L${start}-L${end}` : `#L${start}`) : "";
  return `https://github.com/${repo}/blob/${sha}/${encodedPath}${fragment}`;
};

/** `path:line` display text for a finding's location. Square brackets are
 *  stripped on top of `sanitizeModelText` — the text renders inside `[…](url)`
 *  link syntax, where a `]` would break out of the link. */
const findingLoc = (f: Finding): string => {
  const path = sanitizeModelText(f.path).replace(/[[\]]/g, "");
  return f.startLine === f.endLine
    ? `${path}:${f.startLine}`
    : `${path}:${f.startLine}-${f.endLine}`;
};

/** Sanitized text safe inside a markdown table cell — an unescaped `|` would
 *  split the row. */
const tableCell = (s: string): string => sanitizeModelText(s).replace(/\|/g, "\\|");

/** Comment style preset — operator selects the layout via `pr-review.style`
 *  CONFIG_KV. Hard-coded presets only; never an arbitrary template (model-
 *  authored text would otherwise be able to inject layout). */
const STYLES = ["default", "compact"] as const;
type Style = (typeof STYLES)[number];
const DEFAULT_STYLE: Style = "default";

/** Narrow an arbitrary config string to a known style, or fall back to default. */
const parseStyle = (raw: string | undefined): Style =>
  STYLES.includes(raw as Style) ? (raw as Style) : DEFAULT_STYLE;

/** Default for how many findings the `compact` style lists (most-critical
 *  first) when `pr-review.compact-max` is unset. The full set is still emitted
 *  as check-run annotations regardless of style; this only bounds how many
 *  appear inline in the PR comment before the "…and N more" overflow line.
 *  Mirrors typical "leaderboard" PR-review bots (7 most-critical first). */
const COMPACT_MAX_LISTED_DEFAULT = 7;

/** Upper clamp on the operator-configured compact cap — a comment listing
 *  hundreds of findings inline would blow past sensible PR-comment length and
 *  GitHub's body limit; the overflow → check-run annotations is the relief
 *  valve. 100 is well above any real review. */
const COMPACT_MAX_LISTED_CLAMP = 100;

/** Parse the `pr-review.compact-max` config (how many findings the `compact`
 *  layout lists inline). Accepts a positive integer string; clamps to
 *  1..{@link COMPACT_MAX_LISTED_CLAMP}; falls back to
 *  {@link COMPACT_MAX_LISTED_DEFAULT} for unset / non-numeric / ≤0 values, so a
 *  typo never silently drops the comment to zero rows. */
const parseCompactMax = (raw: string | undefined): number => {
  if (raw === undefined) return COMPACT_MAX_LISTED_DEFAULT;
  const n = Number.parseInt(raw.trim(), 10);
  if (!Number.isInteger(n) || n <= 0) return COMPACT_MAX_LISTED_DEFAULT;
  return Math.min(n, COMPACT_MAX_LISTED_CLAMP);
};

/** Render the consolidated review as a markdown PR comment. `style` picks the
 *  layout: `default` is the verbose verdict-table for power users; `compact` is
 *  the lean leaderboard-bot format ("`## ✅ LGTM`" + 3-col emoji table). */
const renderReviewComment = (
  input: Pick<RunInput, "repo" | "sha">,
  output: Schema.Schema.Type<typeof ReviewOutput>,
  domainCounts: ReadonlyArray<DomainCount>,
  style: Style = DEFAULT_STYLE,
  viewerUrl?: string,
  compactMax: number = COMPACT_MAX_LISTED_DEFAULT,
): string =>
  Match.value(style).pipe(
    Match.when("compact", () => renderCompact(input, output, viewerUrl, compactMax)),
    Match.when("default", () =>
      renderDefault(input, output, domainCounts, viewerUrl),
    ),
    Match.exhaustive,
  );

/** Verbose verdict-table layout: summary table + per-finding details + per-domain
 *  engagement line. Full reviewer transparency — the historical default. */
const renderDefault = (
  input: Pick<RunInput, "repo" | "sha">,
  output: Schema.Schema.Type<typeof ReviewOutput>,
  domainCounts: ReadonlyArray<DomainCount>,
  viewerUrl?: string,
): string => {
  const verdictBadge = Match.value(output.verdict).pipe(
    Match.when("approve", () => "✅ Approve"),
    Match.when("comment", () => "💬 Comment"),
    Match.when("request-changes", () => "🛑 Request changes"),
    Match.exhaustive,
  );

  const header = [
    `### AI code review — ${verdictBadge}`,
    "",
    `Risk tier: \`${output.tier}\` · ${output.critical} critical · ${output.warnings} warnings · ${output.suggestions} suggestions`,
    "",
    // Engagement line: every domain that ran, with its finding count — the
    // counts may exceed the deduped totals above. A domain whose model call
    // failed (and was skipped) shows `⚠️` instead of a count, so a degraded
    // review is visibly partial rather than silently under-reporting.
    `Reviewers: ${domainCounts.map((d) => (d.errored === true ? `${d.agent} ⚠️` : `${d.agent} ${d.count}`)).join(" · ")}`,
  ];

  const rendered = output.findings.slice(0, MAX_RENDERED_FINDINGS);

  const summaryTable = [
    "",
    "| # | Severity | Change required | Location |",
    "| --- | --- | --- | --- |",
    ...rendered.map(
      (f, i) =>
        `| ${i + 1} | ${severityBadge(f.level)} | ${tableCell(f.title)} | [${tableCell(findingLoc(f))}](${findingUrl(input.repo, input.sha, f)}) |`,
    ),
  ];

  const details = rendered.flatMap((f, i) => [
    "",
    `#### ${i + 1}. ${severityBadge(f.level)}${sanitizeModelText(f.title)}`,
    "",
    `📍 [${findingLoc(f)}](${findingUrl(input.repo, input.sha, f)})`,
    "",
    sanitizeModelText(f.message),
  ]);

  const findingsBlock =
    output.findings.length === 0
      ? ["", "_No findings._"]
      : [
          ...summaryTable,
          ...details,
          ...(output.findings.length > MAX_RENDERED_FINDINGS
            ? [
                "",
                `_…and ${output.findings.length - MAX_RENDERED_FINDINGS} more (see check annotations)._`,
              ]
            : []),
        ];

  return [
    ...header,
    ...findingsBlock,
    ...viewerFooter(viewerUrl),
    "",
    COMMENT_MARKER,
  ].join("\n");
};

/** Compact "leaderboard-bot" layout — verdict header (`## ✅ LGTM` /
 *  `⚠️ Minor Issues` / `🚫 Changes Requested`) + 3-col emoji table, max 7 rows.
 *  Built to match the format teams already get from in-house reviewers like
 *  opencode-agent so a FlareDispatch comment lands without a layout cliff. */
const renderCompact = (
  input: Pick<RunInput, "repo" | "sha">,
  output: Schema.Schema.Type<typeof ReviewOutput>,
  viewerUrl?: string,
  compactMax: number = COMPACT_MAX_LISTED_DEFAULT,
): string => {
  const verdictHeader = Match.value(output.verdict).pipe(
    Match.when("approve", () => "## ✅ LGTM"),
    Match.when("comment", () => "## ⚠️ Minor Issues"),
    Match.when("request-changes", () => "## 🚫 Changes Requested"),
    Match.exhaustive,
  );

  if (output.findings.length === 0) {
    return [
      verdictHeader,
      "",
      "No issues found.",
      ...viewerFooter(viewerUrl),
      "",
      COMMENT_MARKER,
    ].join("\n");
  }

  const compactSeverity = (level: Finding["level"]): string =>
    Match.value(level).pipe(
      Match.when("failure", () => "🔴"),
      Match.when("warning", () => "🟡"),
      Match.when("notice", () => "🔵"),
      Match.exhaustive,
    );

  const rendered = output.findings.slice(0, compactMax);
  const overflow = output.findings.length - rendered.length;

  return [
    verdictHeader,
    "",
    "| Severity | Location | Issue |",
    "| --- | --- | --- |",
    ...rendered.map(
      (f) =>
        `| ${compactSeverity(f.level)} | [${tableCell(findingLoc(f))}](${findingUrl(input.repo, input.sha, f)}) | ${tableCell(f.message)} |`,
    ),
    ...(overflow > 0
      ? ["", `_…and ${overflow} more (see check annotations)._`]
      : []),
    ...viewerFooter(viewerUrl),
    "",
    COMMENT_MARKER,
  ].join("\n");
};