§00 / Manifest pre-implementation

Offload the expensive half of GitHub Actions onto Cloudflare.

FlareDispatch moves agentic code review, Playwright e2e, acceptance suites, sharded matrices, and security scans off GitHub Actions and onto Cloudflare — billed per vCPU-second on your own account. GHA stays the trigger; runs take the heavy compute.

§01 / Why it pays off

Heavy CI, at the price of raw serverless compute.

01 / Cost

Pay per vCPU-second

Heavy compute is billed at Cloudflare Container rates — per vCPU-second, scale-to-zero between runs — instead of GitHub Actions per-minute. The jobs that dominate your CI bill move to the cheapest place to run them.

02 / Headroom

No platform ceilings

No 6-hour job limit. No 10 GB cache cap. Artifact retention you set, not a fixed 90 days. The limits that push teams onto self-hosted runners simply aren't there.

03 / Fit

Drops into your setup

GitHub Actions stays the trigger and the PR gate. One wrangler deploy into the Cloudflare account you already pay for. Results land as a GitHub Check Run — no new dashboard, no runner fleet to operate.

§02 / Positioning

Every option trades away one of three things. FlareDispatch keeps all three.

Hosted runners are easy but billed per minute with hard ceilings; self-hosted and hybrid runners drop the ceilings but hand you a fleet to operate. FlareDispatch runs on serverless Cloudflare primitives — in the account you already own.

Heavy-CI option Serverless, scale-to-zero compute No 6 h / 10 GB ceilings No runner fleet to operate
GHA hosted runners
Self-hosted runners
Buildkite Agent (hybrid)
FlareDispatch
§03 / The code

Every recipe — toggle the workflow against its typed run.

Multi-agent agentic code review on every PR. Recommended: Webhook mode. Full recipe →
// 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.
//
// Mode: Webhook mode — fires on every pull_request push, zero GHA minutes,
//       no workflow file. Drop this file into your repo's `runs/`. An
//       Action-mode alternative (./ci.yml) dispatches the same run from a
//       GitHub Actions workflow, for repos that cannot install the App.
// DSL:  see specs/03-dsl.md (uses `config` + `io.priorExecution`); inline
//       findings are posted as check-run annotations — specs/04-gha-integration.md.
//       The checkout rides on the `workspace` primitive — 03-dsl § Primitives.

import { Effect, Schema, Match, Option } from "effect";
import { defineRun, step, sandbox, config, io } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";

// Local helper — true if the PR carries the given label. The webhook
// payload's pull_request.labels is an array of { name }.
const hasLabel = (
  payload: { pull_request: { labels: ReadonlyArray<{ name: string }> } },
  name: string,
): boolean => payload.pull_request.labels.some((l) => l.name === name);

// The domain-scoped reviewers, one per concern (blog: "up to seven
// domain-specific agents"). The risk tier selects which subset actually runs
// — a trivial diff never pays for all seven.
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;

// Each Finding maps 1:1 onto a GitHub check-run annotation — the Dispatcher
// posts these inline on the PR's Files-changed tab, anchored to the exact
// lines (see specs/04-gha-integration.md § Inline findings — annotations).
const Finding = Schema.Struct({
  path: Schema.String,
  startLine: Schema.Number,
  endLine: Schema.Number,
  level: Schema.Literal("notice", "warning", "failure"),
  title: Schema.String,
  message: Schema.String,
});

// The run's output. `findings` becomes the annotation set; the rest renders
// in the check-run summary. Persisted as execution metadata, so the next
// push's re-review can read it back via `io.priorExecution`.
const ReviewOutput = Schema.Struct({
  verdict: Schema.Literal("approve", "comment", "request-changes"),
  tier: Schema.Literal("trivial", "lite", "full"),
  critical: Schema.Number,
  warnings: Schema.Number,
  suggestions: Schema.Number,
  findings: Schema.Array(Finding),
});

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

  triggers: [
    {
      event: "pull_request",
      actions: ["opened", "synchronize", "ready_for_review"],
      idempotencyKey: ({ payload }) =>
        `pr-review:${payload.repository.full_name}:${payload.pull_request.number}:${payload.pull_request.head.sha}`,
      // skip drafts (unless explicitly requested), bots, and opt-outs
      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,
    // Optional: Webhook mode maps it from `payload.installation.id` above;
    // Action mode (./ci.yml) omits it — the Dispatcher resolves the
    // installation from the repo→installation KV map when it opens the
    // check-run. The run body itself never reads it.
    installationId: Schema.optional(Schema.Number),
  }),

  outputs: ReviewOutput,

  // 25-min overall ceiling (blog: "overall (25 min)"); fan-out concurrency is
  // bounded by the largest tier's agent count.
  limits: { maxDurationSec: 1500, maxConcurrency: FULL_AGENTS.length },

  run: (input) =>
    Effect.gen(function* () {
      // 1. Check out the PR head. The `review-agent` CLI is baked into the
      //    run's image, so the checkout needs no dependency install — just a
      //    container + clone, which is `workspace` with `install` off.
      const { container, dir: repoDir } = yield* step("checkout", () =>
        workspace({ repo: input.repo, sha: input.sha }),
      );

      // 2. Build the reviewable diff into DIFF — a *directory* of per-file
      //    patches (blog: `diff_directory`), with lockfiles, minified assets,
      //    and generated code stripped so agents never burn tokens on noise.
      //    Each agent later reads only the slices its domain touches.
      const DIFF = "/tmp/diff";
      yield* step("prepare-diff", () =>
        sandbox.exec({
          cwd: repoDir,
          container,
          command: [
            "review-agent", "diff",
            "--base", input.baseSha,
            "--exclude", "lockfiles,minified,generated",
            "--out", DIFF,
          ],
        }),
      );

      // 3. Risk tier from diff size + touched paths.
      const tierResult = yield* step("classify-risk", () =>
        sandbox.exec({
          cwd: repoDir,
          container,
          command: ["review-agent", "risk-tier", "--diff", DIFF],
        }),
      );

      // 4. The tier IS the plan: which agents run, and which coordinator model.
      //    A trivial diff runs one generalist on a cheap model; a full review
      //    runs all seven and coordinates on the top-tier model (blog: "risk
      //    tiers prevent expensive model calls on trivial changes"). The
      //    `orElse` arm is a deliberate fail-safe — an unrecognized tier string
      //    escalates to the most thorough review, never the cheapest.
      const plan = Match.value(tierResult.stdout.trim()).pipe(
        Match.when("trivial", () => ({
          tier: "trivial" as const, agents: TRIVIAL_AGENTS, model: "sonnet" as const,
        })),
        Match.when("lite", () => ({
          tier: "lite" as const, agents: LITE_AGENTS, model: "sonnet" as const,
        })),
        Match.when("full", () => ({
          tier: "full" as const, agents: FULL_AGENTS, model: "opus" as const,
        })),
        Match.orElse(() => ({
          tier: "full" as const, agents: FULL_AGENTS, model: "opus" as const,
        })),
      );

      // 5. Resolve the coordinator model through the control plane. `config`
      //    is KV-backed: an operator can repoint `opus` at a fallback model in
      //    seconds — no redeploy — when a provider degrades (blog: Workers+KV
      //    control plane). The plan's model is the default when no override.
      const coordinatorModel = yield* step("resolve-model", () =>
        config.get(`pr-review.model.${plan.model}`).pipe(
          Effect.map((override) => override ?? plan.model),
        ),
      );

      // 6. Load the previous execution's findings for this same PR. On a
      //    re-review (a new push) the coordinator uses them to auto-resolve
      //    fixed threads and re-surface unfixed ones (blog: "re-reviews track
      //    previous findings"). Option.none() on the first push.
      const prior = yield* step("load-prior", () =>
        io.priorExecution({
          family: `pr-review:${input.repo}:${input.pr}`,
          outputSchema: ReviewOutput,
        }),
      );

      // 7. Fan out one tightly-scoped agent per domain, in parallel — only the
      //    agents this tier calls for. The shared per-file patches under DIFF
      //    keep token use down across the concurrent reviewers; each writes
      //    findings to /tmp/findings. Code-quality gets a longer leash (blog:
      //    per-task timeouts — 5 min, 10 min for code quality).
      yield* step("review", () =>
        Effect.forEach(
          plan.agents,
          (agent) =>
            sandbox.exec({
              cwd: repoDir,
              container,
              command: [
                "review-agent", "run", agent,
                "--diff", DIFF,
                "--tier", plan.tier,
                "--out", "/tmp/findings",
              ],
              timeoutSec: agent === "code-quality" ? 600 : 300,
            }),
          { concurrency: plan.agents.length },
        ),
      );

      // 8. Seed the coordinator with the prior findings, if any. The findings
      //    set is small in practice (the blog reports ~1.2 findings per
      //    review), so passing it as a CLI arg stays well under ARG_MAX.
      yield* Option.match(prior, {
        onNone: () => Effect.void,
        onSome: (p) =>
          step("seed-prior", () =>
            sandbox.exec({
              cwd: repoDir,
              container,
              command: [
                "review-agent", "seed-previous",
                "--out", "/tmp/previous.json",
                "--json", JSON.stringify(p.output.findings),
              ],
            }),
          ),
      });

      // 9. Coordinator dedups + filters the agents' findings into one verdict
      //    on the resolved model (blog: "coordinator deduplicates and filters
      //    findings"; "bias toward approval unless critical issues found").
      const review = yield* step("coordinate", () =>
        sandbox.exec({
          cwd: repoDir,
          container,
          command: [
            "review-agent", "coordinate",
            "--in", "/tmp/findings",
            "--model", coordinatorModel,
            ...(Option.isSome(prior) ? ["--previous", "/tmp/previous.json"] : []),
            "--json",
          ],
        }),
      );

      // `coordinate --json` emits { verdict, critical, warnings, suggestions,
      // findings }. The check-run summary FlareDispatch posts IS the single
      // consolidated review; each finding additionally lands as an inline
      // check-run annotation. `tier` is stitched in from the plan in step 4.
      const coordinated = JSON.parse(review.stdout) as Omit<
        Schema.Schema.Type<typeof ReviewOutput>,
        "tier"
      >;
      return { ...coordinated, tier: plan.tier };
    }),
});
§04 / Design philosophy

Three bets the design is built on.

  1. §04.01

    Progressive offloading.

    Move CI to Cloudflare one job at a time — the heaviest, most expensive jobs first. GitHub Actions stays the trigger and keeps the cheap jobs; nothing is all-or-nothing.

  2. §04.02

    Runs are typed programs, not YAML.

    A run is an Effect-TS program — Schema-typed inputs and outputs, composable steps, tagged errors, exhaustive matching. Fork it, vendor-edit it, unit-test it without booting a container.

  3. §04.03

    BYOC by construction.

    Every code path assumes the run is deployed in your own Cloudflare account — no multi-tenant operator, no hosted SaaS to trust. The specs are the contract; the runs are yours.

§05 / Read on

Pre-implementation. The specs are the contract.