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.
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. Trigger from a GitHub Actions step or a cron schedule — or skip Actions entirely: install the GitHub App and runs fire themselves on every PR, with zero GitHub Actions minutes and no workflow files to maintain.
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.
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.
Pure webhook mode is the leanest path: install the GitHub App and runs fire themselves on every PR — no .github/workflows file, zero GitHub Actions minutes, one secret to set. Or keep GitHub Actions as the trigger and PR gate when a run must interleave with other jobs. Either way it's one wrangler deploy into the Cloudflare account you already pay for, and results land as a GitHub Check Run — no new dashboard, no runner fleet.
An agentic review is mostly LLM I/O — the run sits blocked on the model while the vCPU is idle. GitHub Actions bills that wait by the wall-clock minute. FlareDispatch runs on Cloudflare Containers, billed per vCPU-second, so the idle wait scales toward zero and you pay for the active slivers — here, roughly a fifth of the run.
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 | ✓ | ✓ | ✓ |
// 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");
}; // Recipe: scheduled AI code-review sweep
//
// The Schedule-mode companion to pr-review.run.ts. Where `pr-review` fires on
// every PR push (Webhook mode), `pr-review-sweep` fires on a wall-clock
// cadence (Schedule mode): each cron tick it enumerates open PRs and fans out
// one `pr-review` execution per PR that still needs one. It contains NO
// review logic — it is pure orchestration. The review itself is the existing
// `pr-review` run, unchanged; the sweep only decides *what* to review and
// *when*.
//
// Mode: Schedule mode — a Cloudflare Cron Trigger drives the Dispatcher's
// scheduled() handler, which instantiates this run as a durable
// scheduling Workflow. See specs/04-gha-integration.md § Schedule mode
// and specs/01-architecture.md § Schedule-mode dispatch.
// DSL: `schedules` on defineRun (03-dsl § schedules); the `github` read
// capability for enumeration (03-dsl § github); `sharded` +
// step.sleepUntil to stagger the fan-out under the GitHub API rate
// limit (03-dsl § Deferred scheduling).
//
// Why this is a *backstop*, not a duplicate channel: every child execution
// keeps the semantic instanceId `pr-review:{repo}:{pr}:{headSha}`, and CF
// Workflows treats a duplicate `create({ id })` as a no-op. So a PR already
// reviewed at its current head SHA by Webhook mode is silently skipped here —
// the sweep only spends tokens on PRs that changed since their last review,
// or that Webhook mode never saw (App installed after the push, a delivery
// dropped, the run added to an existing repo).
import { Effect, Schema } from "effect";
import { defineRun, step, github, io, spawnChildRun } from "@flare-dispatch/core";
import { sharded } from "@flare-dispatch/core/primitives";
// ISO calendar date (UTC) — the cron-window dedup key. One sweep per day.
const isoDate = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
// Coarse scope produced by schedules[].inputs. A cron tick names no target,
// so this is NOT a repo/PR — it is the filter the `enumerate` step applies.
const SweepInput = Schema.Struct({
staleAfterHours: Schema.Number, // only PRs updated within this window
includeDrafts: Schema.Boolean,
firedAt: Schema.Number, // controller.scheduledTime, epoch ms
});
// The sweep's own output is a digest written to D1 execution metadata. The
// sweep posts no check-run of its own — it is not anchored to a commit; each
// child `pr-review` posts its own per-PR check-run (01-architecture
// § Schedule-mode dispatch).
const SweepOutput = Schema.Struct({
prsFound: Schema.Number,
dispatched: Schema.Number, // child pr-review executions actually created
skipped: Schema.Number, // already reviewed at head SHA — no-op create
});
export const prReviewSweep = defineRun({
name: "pr-review-sweep",
version: "1.0.0",
// Schedule mode: 03:00 UTC daily. This expression MUST also appear in
// wrangler.jsonc `triggers.crons` — that array is what Cloudflare actually
// subscribes to; `schedules` is how the scheduled() handler routes the
// firing `controller.cron` back to this run (05-byoc § Wrangler config).
schedules: [
{
cron: "0 3 * * *",
// Cron-window key — collapses a duplicate cron delivery before any
// Workflow is touched (04-gha-integration § Receiver dedup). The
// scheduling Workflow's instanceId derives from this.
idempotencyKey: ({ firedAt }) => `pr-review-sweep:${isoDate(firedAt)}`,
// Receiver-side gate: skip weekends, before any compute is spent.
gate: ({ firedAt }) => {
const day = new Date(firedAt).getUTCDay();
return day !== 0 && day !== 6;
},
inputs: ({ firedAt }) => ({
staleAfterHours: 24,
includeDrafts: false,
firedAt,
}),
},
],
inputs: SweepInput,
outputs: SweepOutput,
// The sweep spends almost all of its wall-clock hibernating on staggered
// `sleepUntil` checkpoints, not consuming CPU. The ceiling covers the
// 45-min stagger window plus enumeration headroom.
limits: { maxDurationSec: 5400, maxConcurrency: 100 },
run: (input) =>
Effect.gen(function* () {
// 1. Enumerate. A cron tick names no target — discovery is the first
// step. `github.openPullRequests` is the runtime-provided,
// App-token-backed read surface; it spans every repo the
// FlareDispatch App is installed on (03-dsl § github).
const prs = yield* step("enumerate", () =>
github.openPullRequests({
updatedWithinHours: input.staleAfterHours,
includeDrafts: input.includeDrafts,
}),
);
yield* step("log-scope", () =>
io.log("info", `sweep found ${prs.length} open PR(s)`, {
firedAt: input.firedAt,
}),
);
// 2. Fan out one `pr-review` per PR, staggered evenly across 45 min so
// the GitHub API and the model provider never see a burst. Each
// child is created with the SEMANTIC instanceId — so a PR already
// reviewed at its current head SHA (by Webhook mode, or an earlier
// sweep) is a no-op create that this run records as `skipped`.
const STAGGER_MS = 45 * 60_000;
const outcomes = yield* sharded({
count: prs.length,
concurrency: prs.length, // each child is just a create() + sleep
body: ({ index, total }) =>
Effect.gen(function* () {
const pr = prs[index - 1];
const offset = Math.floor((STAGGER_MS / Math.max(total, 1)) * (index - 1));
// Durable sleep — the Workflow hibernates, consuming no CPU and
// surviving eviction, until this child's slot in the window.
yield* step.sleepUntil(
`stagger-${pr.repo}-${pr.number}`,
input.firedAt + offset,
);
return yield* step(`dispatch-${pr.repo}-${pr.number}`, () =>
spawnChildRun({
run: "pr-review",
// Semantic id — identical to the key Webhook mode uses, so
// the two modes dedup against each other for free.
instanceId: `pr-review:${pr.repo}:${pr.number}:${pr.headSha}`,
input: {
repo: pr.repo,
sha: pr.headSha,
baseSha: pr.baseSha,
pr: pr.number,
installationId: pr.installationId,
},
}),
);
}),
});
// `spawnChildRun` reports whether the create() actually started a new
// instance (`created: true`) or collapsed onto an existing one
// (`created: false` — already reviewed at this head SHA).
const dispatched = outcomes.filter((o) => o.created).length;
return {
prsFound: prs.length,
dispatched,
skipped: prs.length - dispatched,
};
}),
}); # Recipe: AI code review — Action-mode alternative
#
# Use case: same `pr-review` run as ./pr-review.run.ts, but dispatched from a
# GitHub Actions workflow instead of the FlareDispatch GitHub App webhook.
#
# Webhook mode (the `triggers` block in ./pr-review.run.ts) is preferred — it
# burns zero GHA minutes and the run's own `gate` handles draft / bot /
# opt-out filtering. Use this Action-mode file when the App is not installed,
# or you want the review dispatch to sit alongside other PR jobs.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: pr-review — defined in ./pr-review.run.ts.
name: ai-code-review
on:
pull_request:
types: [opened, synchronize, ready_for_review]
jobs:
ai-code-review:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: pr-review
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"baseSha": "${{ github.event.pull_request.base.sha }}",
"pr": ${{ github.event.pull_request.number }}
}
# `installationId` is intentionally absent from `inputs`: the Dispatcher
# resolves the installation from the repo→installation KV map when it opens
# the check-run (specs/04-gha-integration.md § Check-runs callback), so the
# Action need not — and cannot reliably — supply it from the GHA context.
#
# Findings post back as inline check-run annotations on the PR's Files-changed
# tab — require the check-run `flare-dispatch/pr-review` in branch protection. # Recipe: browser tests (Playwright e2e) on Cloudflare
#
# Use case: a Playwright suite that is slow and burns GHA minutes. Offload it
# to the shipped `playwright-e2e` run, which shards the suite across the
# Cloudflare Browser Rendering pool. GHA only fires the dispatch (~10 s) and
# the result comes back as a check-run.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: playwright-e2e — see specs/02-runs.md#3-playwright-e2e.
name: e2e
on:
pull_request:
paths: ["apps/**", "packages/**"]
jobs:
browser-tests:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: playwright-e2e
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"baseURL": "https://staging.example.com",
"shards": 4,
"browserMode": "cf-browser-rendering",
"uploadReport": true
}
# Branch protection: require the check-run `flare-dispatch/playwright-e2e`,
# not this GHA job — the job succeeds the moment dispatch is accepted. // Recipe: browser tests — the `playwright-e2e` Run
//
// Re-exports the canonical run from runs/playwright-e2e.ts (the Dispatcher's
// registered implementation). Copy the runs/playwright-e2e.ts file verbatim
// into your own repo when forking this recipe — the indirection here keeps
// the recipe page in sync with the registered run without duplicating code.
//
// Spec: specs/02-runs.md § 3. DSL: specs/03-dsl.md.
export { playwrightE2E } from "@flare-dispatch/runs/playwright-e2e"; # Recipe: sharded test matrix on Cloudflare
#
# Use case: a unit/integration suite that is fast per-shard but long in total.
# Offload it to the shipped `matrix-fanout` run, which spawns N child
# Workflows (one container per shard) and reports a single parent check-run
# that is green only if every shard passes.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: matrix-fanout — see specs/02-runs.md#2-matrix-fanout.
name: test-matrix
on:
pull_request:
jobs:
test-matrix:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: matrix-fanout
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"command": "pnpm test --shard $SHARD_INDEX/$SHARD_TOTAL",
"shards": 8,
"failureBehavior": "wait-all"
}
# Each shard receives SHARD_INDEX / SHARD_TOTAL in its environment.
# Require the check-run `flare-dispatch/matrix-fanout` in branch protection. // Recipe: sharded test matrix — the `matrix-fanout` Run
//
// Re-exports the canonical run from runs/matrix-fanout.ts (the Dispatcher's
// registered implementation). Copy the runs/matrix-fanout.ts file verbatim
// into your own repo when forking this recipe — the indirection here keeps
// the recipe page in sync with the registered run without duplicating code.
//
// Spec: specs/02-runs.md § 2. DSL: specs/03-dsl.md.
export { matrixFanout } from "@flare-dispatch/runs/matrix-fanout"; # Recipe: CDP acceptance tests on Cloudflare
#
# Use case: acceptance tests that boot the app under test and drive it via
# the Chrome DevTools Protocol — asserting on network calls, console errors,
# document counts, heap deltas. Offload to the shipped `cdp-acceptance` run,
# which boots the app in a container and attaches Browser Rendering over CDP.
#
# Mode: Action mode, await — a follow-up deploy gate needs the result inline.
# See specs/04-gha-integration.md#await-sub-mode.
# Run: cdp-acceptance — see specs/02-runs.md#4-cdp-acceptance.
name: acceptance
on:
pull_request:
paths: ["apps/**"]
jobs:
cdp-acceptance:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: cdp-acceptance
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
mode: await
timeout: 30m # must be >= the cdp-acceptance run's maxDurationSec (1800s)
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"appBootCommand": "pnpm dev",
"appPort": 4173,
"testCommand": "pnpm test:acceptance"
}
# In await mode the action mirrors the run's conclusion onto this GHA step,
# so a subsequent deploy job can `needs:` it. // Recipe: CDP acceptance tests — the `cdp-acceptance` Run
//
// The typed Run that ./ci.yml dispatches. Boots the app under test in a
// detached container, attaches Browser Rendering over the Chrome DevTools
// Protocol, runs the acceptance suite, and uploads screenshots + a trace as
// artifacts (see ./README.md — those can be attached to the PR).
//
// This recipe rides on two primitives — `workspace` (acquire + clone +
// cached install) and `bootApp` (detached run + wait-for-port) — so the file
// carries only the CDP-specific logic. See specs/03-dsl.md § Primitives.
//
// This recipe is the minimal illustration of the run shape. The shipped run is
// `runs/cdp-acceptance.ts` — it additionally injects credentials from the
// config store via the `loadSecrets` primitive (see its header). Recipe scope:
// specs/02-runs.md § 4. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, browser, artifact, io } from "@flare-dispatch/core";
import { workspace, bootApp } from "@flare-dispatch/core/primitives";
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
appBootCommand: Schema.String, // e.g. "pnpm dev"
appPort: Schema.Number, // e.g. 4173
testCommand: Schema.String, // e.g. "pnpm test:acceptance"
});
const Output = Schema.Struct({
exitCode: Schema.Number,
reportUri: Schema.String, // HTML report
screenshotsUri: Schema.String, // screenshots + trace — attachable to the PR
});
export const cdpAcceptance = defineRun({
name: "cdp-acceptance",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1800, requiresBrowser: true },
run: (input) =>
Effect.gen(function* () {
// Acquire a container, clone the target SHA, and install dependencies
// from the R2-backed cache — the whole checkout dance is one primitive.
const { container, dir } = yield* step("checkout", () =>
workspace({ repo: input.repo, sha: input.sha, install: true }),
);
// Boot the app in a detached container and block until its port opens.
yield* step("boot-app", () =>
bootApp({
container,
dir,
command: input.appBootCommand,
port: input.appPort,
timeoutSec: 120,
}),
);
// Expose the app port as a public preview URL — the browser runs in
// Cloudflare's cloud and cannot reach the container's `localhost`, so the
// suite navigates to this reachable URL (handed over as CDP_TARGET_URL).
const exposed = yield* step("expose-app", () =>
sandbox.exposePort({ container, port: input.appPort }),
);
// Attach Browser Rendering over CDP and run the acceptance suite. The
// suite drives the app and writes screenshots/traces under ./artifacts.
// Persist only the `wsEndpoint` string: a `CDPSession` carries a `close`
// Effect, which a CF Workflow `step` checkpoint cannot structured-clone
// (`DataCloneError`). See runs/cdp-acceptance.ts attach-cdp note.
const cdpWsUrl = yield* step("attach-cdp", () =>
browser
.newCDPSession({ targetUrl: exposed.url })
.pipe(Effect.map((session) => session.wsEndpoint)),
);
const exec = yield* step("run-tests", () =>
sandbox.exec({
cwd: dir,
container,
env: { CDP_WS_URL: cdpWsUrl, CDP_TARGET_URL: exposed.url },
command: input.testCommand,
}),
);
// Upload the report and the screenshots/trace bundle. Both come back as
// signed R2 URLs in the check-run summary; a developer can drop the
// screenshots or the demo recording straight into the PR.
const reportUri = yield* step("upload-report", () =>
artifact.upload({
name: "acceptance-report",
path: `${dir}/playwright-report/`,
container,
signedUrlTTL: "30 days",
}),
);
const screenshotsUri = yield* step("upload-screenshots", () =>
artifact.upload({
name: "screenshots",
path: `${dir}/artifacts/`,
container,
signedUrlTTL: "30 days",
}),
);
yield* io.log("info", `cdp-acceptance exited ${exec.exitCode}`);
return { exitCode: exec.exitCode, reportUri, screenshotsUri };
}),
}); # Recipe: AI-driven product demo on Cloudflare
#
# Use case: produce a walkthrough video + per-story narrative of a deployed
# site (preview, staging, or prod) so a reviewer can SEE the change work
# without checking anything out locally. Offload to the `product-demo` run,
# which attaches Browser Rendering over CDP, records ONE master video while
# driving the site through each story, and writes a holistic summary.
#
# Mode: Action mode, fire-and-forget. The run posts the result through the
# `flare-dispatch/product-demo` check-run — the video link + the
# summary land in the check-run summary, which reviewers paste into
# the PR. See specs/04-gha-integration.md.
# Run: product-demo — see ./product-demo.run.ts.
name: product-demo
on:
# On every PR touching the app, drive a fixed default story set against
# the PR's preview deployment URL (a `vars.PREVIEW_URL_TEMPLATE` or a
# deploy-env URL — configure per repo).
pull_request:
paths: ["apps/**"]
# On-demand against any URL (preview, staging, prod). Optionally override the
# tracked markdown script inline. Useful for QA, release sign-off, marketing.
workflow_dispatch:
inputs:
deployed_url:
description: "URL to drive (preview / staging / prod)"
required: true
stories_markdown:
description: "Markdown story script (each `## ` heading = one chapter). Blank ⇒ the tracked demo-stories.md."
required: false
jobs:
product-demo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The story script lives as readable markdown (each `## ` heading = one
# chapter; see the README § "Authoring stories"), not inline JSON. Read
# the tracked demo-stories.md into a step output — unless the dispatch
# passed an inline override. The run parses `storiesMarkdown` into the
# same {name, prose} list the structured `stories` array produces.
- name: Load demo stories
id: stories
run: |
{ echo 'md<<DEMO_STORIES_EOF'
if [ -n "${OVERRIDE}" ]; then printf '%s\n' "${OVERRIDE}"; else cat recipes/product-demo/demo-stories.md; fi
echo DEMO_STORIES_EOF
} >> "$GITHUB_OUTPUT"
env:
OVERRIDE: ${{ github.event.inputs.stories_markdown }}
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: product-demo
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.event.pull_request.head.sha || github.sha }}",
"deployedUrl": ${{ toJSON(github.event.inputs.deployed_url || vars.PREVIEW_URL) }},
"storiesMarkdown": ${{ toJSON(steps.stories.outputs.md) }},
"viewportPreset": "desktop"${{ github.event.pull_request.number && format(', "pr": {0}', github.event.pull_request.number) || '' }}
}
# `pr` is what enables the GIF-on-completion comment: on a `pull_request`
# the run posts the holistic summary + an inline walkthrough GIF to the
# PR. On `workflow_dispatch` there is no PR, so it is omitted and the
# run reports via the `flare-dispatch/product-demo` check-run only. For
# the GIF to render inline, the deploy's `/v1/artifacts/...` route must
# be public (GitHub's image proxy fetches it server-side).
#
# Prefer the structured array? Swap the `"storiesMarkdown"` line for
# `"stories": [{ "name": "...", "prose": "..." }]` and drop the "Load
# demo stories" step — `stories` wins if both are supplied.
# Fire-and-forget: this GHA step succeeds as soon as the dispatch is
# accepted; the actual demo runs on Cloudflare and reports back through the
# `flare-dispatch/product-demo` check-run on the PR head SHA. Require that
# check (not this GHA job) in branch protection. The check-run summary
# embeds the signed video URL — reviewers drop it into the PR description. // Recipe: AI-driven product demo — the `product-demo` Run
//
// The typed Run that ./ci.yml dispatches. A team hands it a deployed URL
// (preview / staging / prod) and a list of user stories as prose; the run
// drives the site through each story in sequence over a single CDP
// session with Browser Run's native rrweb session recording enabled,
// captures key screenshots, writes a per-story narrative, and then
// produces a holistic markdown summary across all stories that a reviewer
// can paste into the PR.
//
// No checkout — the target is a deployed URL, not the repo. The run only
// attaches to Browser Run over CDP and shells out to the bundled
// `demo-agent` CLI (baked into the `flare-dispatch-demo:latest` image, the
// same shape as `review-agent` in recipes/ai-code-review). The agent owns
// the model loop and CDP action application; the platform owns recording
// (Browser Run records rrweb DOM events at the session level when the CDP
// connect URL carries `?recording=true`); this run only orchestrates:
// attach → record start (set viewport, capture sessionId) → for each
// story play → record stop (close session, pull rrweb events from the
// Browser Run REST API, upload as R2 JSON) → summarize.
//
// Mode: Action — dispatched by ./ci.yml on `pull_request` and on manual
// `workflow_dispatch`. See specs/04-gha-integration.md § Action mode.
// DSL: uses `browser.newCDPSession`, `sandbox.exec`, `artifact.upload`,
// `config.get`, and `io.priorExecution` — specs/03-dsl.md § Capabilities.
import { Effect, Schema, Option } from "effect";
import {
defineRun,
step,
sandbox,
browser,
artifact,
config,
io,
} from "@flare-dispatch/core";
import { loadSecrets } from "@flare-dispatch/core/primitives";
// A user story is just a name + prose. The agent reads the prose, decides
// the next browser action, applies it over the live CDP session, captures
// screenshots, and emits a narrative. Names must be unique within the
// `stories` array — they become chapter markers on the rrweb replay timeline.
const Story = Schema.Struct({
name: Schema.String,
prose: Schema.String,
});
/**
* Parse a markdown stories doc into `{ name, prose }[]` — each level-2 ATX
* heading (`## `) is one story (heading = name, body until the next `## ` =
* prose). Content before the first heading is ignored. Pure + deterministic.
* The canonical copy lives in `runs/product-demo.ts`; kept in sync here so the
* teaching example shows the markdown-authoring path.
*/
export const parseStoriesMarkdown = (
markdown: string,
): ReadonlyArray<{ name: string; prose: string }> => {
const headingRe = /^ {0,3}##\s+(.+?)\s*#*\s*$/;
const stories: Array<{ name: string; prose: string[] }> = [];
let current: { name: string; prose: string[] } | null = null;
for (const line of markdown.split(/\r?\n/)) {
const match = headingRe.exec(line);
if (match) {
current = { name: match[1]!.trim(), prose: [] };
stories.push(current);
continue;
}
if (current) current.prose.push(line);
}
return stories.map((s) => ({
name: s.name,
prose: s.prose.join("\n").trim(),
}));
};
const Input = Schema.Struct({
// The deployed URL the demo runs against. No checkout — this is the
// target site, not the repo. `repo` and `sha` are still required because
// the check-run callback anchors to them (specs/04-gha-integration.md
// § Check-runs callback).
repo: Schema.String,
sha: Schema.String,
deployedUrl: Schema.String,
// Provide exactly one: `stories` (structured `{ name, prose }[]`) or
// `storiesMarkdown` (a markdown doc, one `## ` heading per story). `stories`
// wins if both are present; the run resolves the effective list below.
stories: Schema.optional(Schema.Array(Story)),
storiesMarkdown: Schema.optional(Schema.String),
// The viewport preset is passed through to the agent — it sets the CDP
// viewport once at attach time via Emulation.setDeviceMetricsOverride so
// every rrweb event in the session uses one resolution. Default desktop;
// "mobile" emulates a phone profile in the agent.
viewportPreset: Schema.optional(Schema.Literal("desktop", "mobile")),
// Per-story wall-clock ceiling. Stories run sequentially against ONE
// rrweb-recorded session so the replay timeline is continuous; without
// a per-story cap one stuck story would burn the whole run's
// `maxDurationSec`.
maxDurationSecPerStory: Schema.optional(Schema.Number),
});
// Each story round-trips its own per-chapter record so the summary can
// point a reviewer at the exact span of the rrweb replay to look at.
const StoryResult = Schema.Struct({
name: Schema.String,
status: Schema.Literal("passed", "failed"),
durationMs: Schema.Number,
// rrweb timestamps in ms from session start — a reviewer can jump straight
// to a story's chapter in the replay player by seeking to chapterStartMs.
chapterStartMs: Schema.Number,
chapterEndMs: Schema.Number,
narrative: Schema.String,
keyScreenshotUri: Schema.String,
});
const Output = Schema.Struct({
// The docs-site rrweb player URL (https://<docsBase>/replay/<sessionId>).
// Reviewers click through to scrub the recording. The replay UI itself
// ships separately — see specs/pm/plan.md § Replay UI; until then this
// links to a "coming soon" page that displays the raw replayJsonUri.
replayUri: Schema.String,
// Signed R2 URL to the raw rrweb event JSON (30-day TTL). Power-user
// escape hatch — drop into an rrweb-player iframe to self-host the
// replay. The dispatcher mirrors Browser Run's 30-day retention.
replayJsonUri: Schema.String,
summaryMd: Schema.String, // the holistic LLM-written summary
stories: Schema.Array(StoryResult),
});
export const productDemo = defineRun({
name: "product-demo",
version: "1.0.0",
// The operator's sandbox image (the one bound to `RUNS_SANDBOX` via
// `wrangler.jsonc`) MUST include the `demo-agent` binary on PATH — model
// loop, CDP-driver glue, and the Browser Run recording REST client.
// FlareDispatch has one container binding per Worker, pinned by
// `infra/Dockerfile.sandbox`; drop the `demo-agent` layer from
// `recipes/product-demo/Dockerfile.example` into your own sandbox image
// to enable this run.
inputs: Input,
outputs: Output,
// Stories run SEQUENTIALLY against one CDP session so the rrweb timeline
// stays continuous. `maxConcurrency: 1` makes the sequencing explicit and
// keeps the Browser Run session count to 1. `requiresBrowser: true`
// reserves a slot in the Browser Run pool — and the dispatcher's
// `newCDPSession` primitive appends `?recording=true` so Browser Run
// captures the rrweb event stream for the session's lifetime.
limits: { maxDurationSec: 3600, maxConcurrency: 1, requiresBrowser: true },
run: (input) =>
Effect.gen(function* () {
const viewport = input.viewportPreset ?? "desktop";
const perStorySec = input.maxDurationSecPerStory ?? 180;
// -1. Resolve the effective story list before any browser/sandbox work.
// `stories` wins over `storiesMarkdown`; the markdown is parsed into
// the same `{ name, prose }` shape, so nothing downstream is markdown
// -aware. Die loudly on a payload with neither / on duplicate names.
const resolvedStories =
input.stories !== undefined && input.stories.length > 0
? input.stories
: input.storiesMarkdown !== undefined
? parseStoriesMarkdown(input.storiesMarkdown)
: [];
if (resolvedStories.length === 0) {
return yield* Effect.die(
"product-demo: no stories to play — supply a non-empty `stories` array, " +
"or a `storiesMarkdown` doc with at least one `## ` heading.",
);
}
const duplicateNames = [
...new Set(
resolvedStories
.map((s) => s.name)
.filter((n, i, a) => a.indexOf(n) !== i),
),
];
if (duplicateNames.length > 0) {
return yield* Effect.die(
`product-demo: duplicate story names ${JSON.stringify(duplicateNames)} — ` +
"each story name becomes a unique rrweb chapter marker.",
);
}
// 0. Resolve the demo-agent's runtime credentials from CONFIG_KV. The
// container holds NO ambient credentials. The agent is
// provider-agnostic on `@effect/ai`'s `LanguageModel` Tag and speaks
// the OpenAI wire format, always routed through a Cloudflare AI
// Gateway; the upstream is the operator's gateway config.
// * `CF_AI_GATEWAY_ID` — gateway slug. The endpoint
// `https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/compat`
// is derived from this + `CLOUDFLARE_ACCOUNT_ID` (no
// `MODEL_BASE_URL`).
// * `CLOUDFLARE_ACCOUNT_ID` — account that owns the gateway AND the
// Browser Rendering session.
// * `CLOUDFLARE_API_TOKEN` — recording REST fetch auth.
// * `MODEL_API_KEY` (optional) — upstream provider key
// (`Authorization: Bearer`); unset under gateway BYOK.
// * `CF_AI_GATEWAY_TOKEN` (optional) — the gateway's own auth
// (`cf-aig-authorization`), for an Authenticated Gateway.
// Orthogonal to `MODEL_API_KEY`.
const requiredAgentEnv = yield* loadSecrets(
["CF_AI_GATEWAY_ID", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
{ prefix: "product-demo.secret/", required: true },
);
const optionalAgentEnv = yield* loadSecrets(
["MODEL_API_KEY", "CF_AI_GATEWAY_TOKEN"],
{ prefix: "product-demo.secret/" },
);
const agentEnv = { ...requiredAgentEnv, ...optionalAgentEnv };
// 1. Attach Browser Run over CDP against the DEPLOYED URL. No
// checkout, no app boot — the site is already live. The dispatcher's
// `newCDPSession` primitive composes the connect URL with
// `?recording=true` so Browser Run records rrweb DOM events for
// the whole session; the agent doesn't have to start recording —
// it inherits a session that's already recording.
const session = yield* step("attach-cdp", () =>
browser.newCDPSession({ targetUrl: input.deployedUrl }),
);
// Filesystem layout inside the container — relative paths the agent
// writes through. The session-id file is the handoff between
// `record-start` (which queries it via puppeteer.sessionId()) and
// `record-stop` (which uses it to call Browser Run's recording REST
// endpoint after the session closes).
const sessionIdPath = "/tmp/demo/session-id";
const replayJsonPath = "/tmp/demo/replay.json";
const screenshotsDir = "/tmp/demo/screenshots";
const storiesJsonPath = "/tmp/demo/stories.json";
const summaryPath = "/tmp/demo/summary.md";
// 2. Validate the connect URL carries `?recording=true`, set the
// viewport once (one resolution for the whole rrweb stream), and
// capture the session ID for the REST pull in step 4. The agent
// does NOT start a recording pipeline of its own — Browser Run is
// already recording on the platform side.
yield* step("record-start", () =>
sandbox.exec({
command: [
"demo-agent", "record", "start",
"--cdp-ws", session.wsEndpoint,
"--viewport", viewport,
"--session-id-out", sessionIdPath,
],
env: agentEnv,
}),
);
// 2.5. Resolve per-step model ids through CONFIG_KV — same seam as
// `pr-review`. Both required; no provider-specific default lives
// in code (a default like `claude-opus-4-7` is meaningless if the
// gateway is pointed at OpenAI / Workers AI / Bedrock). Unset
// keys die loudly.
const playModel = yield* step("resolve-play-model", () =>
config.get("product-demo.model.play").pipe(
Effect.flatMap((v) =>
v !== undefined && v !== ""
? Effect.succeed(v)
: Effect.die(
"CONFIG_KV missing required key: product-demo.model.play",
),
),
),
);
// 3. Walk the stories in order. Each `demo-agent play` reads the
// prose, applies actions over the SAME CDP session (so the rrweb
// timeline stays continuous), captures key screenshots into
// `screenshotsDir`, and emits one JSON line per story with the
// chapter offsets (rrweb timestamps), status, narrative, and the
// key-screenshot path. Concurrency 1 — see `limits` above.
const playResults = yield* step("play-stories", () =>
Effect.forEach(
resolvedStories,
(story) =>
sandbox.exec({
command: [
"demo-agent", "play",
"--cdp-ws", session.wsEndpoint,
"--name", story.name,
"--prose", story.prose,
"--screenshots", screenshotsDir,
"--max-sec", String(perStorySec),
"--model", playModel,
],
env: agentEnv,
timeoutSec: perStorySec + 30,
}),
{ concurrency: 1 },
),
);
// 4. Close the CDP session and pull the recording. Browser Run only
// finalizes rrweb events after the session closes; `demo-agent
// record stop` closes the session, polls
// GET /accounts/<id>/browser-rendering/recording/<sessionId>, writes
// the event array to `--out`, and emits a JSON last-line with the
// sessionId + the realized event count.
const recordStopResult = yield* step("record-stop", () =>
sandbox.exec({
command: [
"demo-agent", "record", "stop",
"--cdp-ws", session.wsEndpoint,
"--session-id-in", sessionIdPath,
"--out", replayJsonPath,
],
env: agentEnv,
}),
);
// 5. Parse each `play` step's JSON stdout. The agent's contract: one
// JSON object per invocation on the LAST line of stdout (anything
// before is logs). The shape is fixed by the agent — mirrors the
// `review-agent coordinate --json` pattern in recipes/ai-code-review.
type PlayJson = {
status: "passed" | "failed";
durationMs: number;
chapterStartMs: number;
chapterEndMs: number;
narrative: string;
keyScreenshotPath: string;
};
const parsed = playResults.map((r, i) => {
const lastLine = r.stdout.trim().split("\n").pop() ?? "{}";
return {
story: resolvedStories[i],
json: JSON.parse(lastLine) as PlayJson,
};
});
type RecordStopJson = { sessionId: string; eventCount: number };
const recordStopJson = JSON.parse(
recordStopResult.stdout.trim().split("\n").pop() ?? "{}",
) as RecordStopJson;
// 6. Upload the rrweb event JSON once, signed for 30 days so the link
// survives PR-review cycles. Reviewers paste the URL straight into
// the PR description, or feed it to an rrweb-player iframe.
const replayJsonUri = yield* step("upload-replay-json", () =>
artifact.upload({
name: "replay.json",
path: replayJsonPath,
contentType: "application/json",
signedUrlTTL: "30 days",
}),
);
// 7. Upload each story's key screenshot in parallel (concurrency 4
// — independent uploads, no shared state). Each returns its own
// signed URL embedded into the per-story result.
const screenshotUris = yield* step("upload-screenshots", () =>
Effect.forEach(
parsed,
(p) =>
artifact.upload({
name: `${p.story.name}.png`,
path: p.json.keyScreenshotPath,
contentType: "image/png",
signedUrlTTL: "30 days",
}),
{ concurrency: 4 },
),
);
// 8. Resolve the summary model id (required, same shape as
// `product-demo.model.play` above) and the docs-site base. Docs
// base IS tuning, not gating, so it keeps a default; the model id
// has no provider-neutral default and dies loudly when unset.
const summaryModel = yield* step("resolve-summary-model", () =>
config.get("product-demo.model.summary").pipe(
Effect.flatMap((v) =>
v !== undefined && v !== ""
? Effect.succeed(v)
: Effect.die(
"CONFIG_KV missing required key: product-demo.model.summary",
),
),
),
);
const docsBase = yield* step("resolve-docs-base", () =>
config.get("product-demo.docsBase").pipe(
Effect.map(
(override) => override ?? "https://flare-dispatch.openhackersclub.com",
),
),
);
const replayUri = `${docsBase}/replay/${recordStopJson.sessionId}`;
// 9. Stitch typed per-story results, then write them to disk for the
// agent's summarizer. Doing the file write through `sandbox.exec`
// keeps everything inside the container — the agent and the file
// sit on the same filesystem.
const stories = parsed.map((p, i) => ({
name: p.story.name,
status: p.json.status,
durationMs: p.json.durationMs,
chapterStartMs: p.json.chapterStartMs,
chapterEndMs: p.json.chapterEndMs,
narrative: p.json.narrative,
keyScreenshotUri: screenshotUris[i],
}));
yield* step("write-stories-json", () =>
sandbox.exec({
command: [
"demo-agent", "write-json",
"--out", storiesJsonPath,
"--data", JSON.stringify({ stories, replayUri, replayJsonUri }),
],
env: agentEnv,
}),
);
// 10. Load the previous execution for this (repo, deployedUrl) so
// `summarize` can call out what's new / regressed since the last
// demo run. `Option.match` — never `_tag` access (CLAUDE.md
// Effect-TS rules).
const prior = yield* step("load-prior", () =>
io.priorExecution({
family: `product-demo:${input.repo}:${input.deployedUrl}`,
outputSchema: Output,
}),
);
// 11. Hand the previous summary to the agent as a file IF it exists.
// Seeding via an intermediate file (instead of `--previous-json
// '<...>'`) avoids ARG_MAX surprises when the prior summary is
// long-form prose.
const previousArgs = yield* Option.match(prior, {
onNone: () => Effect.succeed<readonly string[]>([]),
onSome: (p) =>
step("seed-prior", () =>
sandbox.exec({
command: [
"demo-agent", "write-prior",
"--out", "/tmp/demo/previous.md",
"--data", p.output.summaryMd,
],
env: agentEnv,
}).pipe(Effect.as(["--previous", "/tmp/demo/previous.md"] as const)),
),
});
// 12. Generate the holistic summary. The agent emits the markdown to
// stdout AND writes it to `--out`; we read it from stdout because
// `io` has no file-read primitive — see specs/03-dsl.md § io.
const summaryResult = yield* step("summarize", () =>
sandbox.exec({
command: [
"demo-agent", "summarize",
"--stories-json", storiesJsonPath,
"--model", summaryModel,
"--out", summaryPath,
...previousArgs,
],
env: agentEnv,
}),
);
yield* io.log(
"info",
`product-demo: ${stories.length} stories, ${stories.filter((s) => s.status === "passed").length} passed, ${recordStopJson.eventCount} rrweb events`,
);
// The check-run summary the Dispatcher posts EMBEDS `summaryMd`
// verbatim and links `replayUri` — reviewers see the holistic write-up
// on the PR's Checks tab and one signed replay link they can drop
// straight into the PR description (see specs/04-gha-integration.md
// § Inline findings — summary).
return {
replayUri,
replayJsonUri,
summaryMd: summaryResult.stdout.trim(),
stories,
};
}),
}); # Recipe: security / dependency scan on Cloudflare
#
# Use case: dependency and vulnerability scanning that you want on every PR
# *and* on a weekly schedule (to catch newly-disclosed CVEs in code that
# hasn't changed). Offload to the shipped `security-scan` run, which runs
# each selected scanner in its own container in parallel.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: security-scan — see specs/02-runs.md#5-security-scan.
name: security-scan
on:
pull_request:
schedule:
- cron: "0 6 * * 1" # every Monday 06:00 UTC
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: security-scan
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"scanners": ["pnpm-audit", "trivy-fs", "gitleaks"],
"failOn": "high"
}
# The scheduled run uses github.sha of the default branch — the same run
# slug, no extra wiring. Findings land in the check-run summary. // Recipe: security / dependency scan — the `security-scan` Run
//
// The typed Run that ./ci.yml dispatches. Runs each selected scanner in its
// own container, in parallel, and fails the check-run if any scanner exits
// non-zero (each scanner is configured to exit non-zero at/above `failOn`).
//
// The scanners are a heterogeneous list, so the fan-out stays plain
// `Effect.forEach` rather than the count-based `sharded` primitive — but each
// scanner's container + checkout is the `workspace` primitive. See
// specs/03-dsl.md § Primitives.
//
// This is the shipped `security-scan` run, reproduced here so the recipe is
// self-contained. Spec: specs/02-runs.md § 5. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, artifact } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";
const Scanner = Schema.Literal(
"npm-audit", "pnpm-audit", "cargo-audit", "uv-audit",
"trivy-fs", "grype-fs", "gitleaks",
);
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
scanners: Schema.Array(Scanner),
failOn: Schema.optional(Schema.Literal("any", "high", "critical")), // default "high"
});
const Output = Schema.Struct({
failed: Schema.Boolean,
scannerResults: Schema.Array(
Schema.Struct({
scanner: Schema.String,
exitCode: Schema.Number,
reportUri: Schema.String,
}),
),
});
export const securityScan = defineRun({
name: "security-scan",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1200, maxConcurrency: 4 },
run: (input) =>
Effect.gen(function* () {
const failOn = input.failOn ?? "high";
const scannerResults = yield* step("scan", () =>
Effect.forEach(
input.scanners,
(scanner) =>
Effect.gen(function* () {
const { container, dir } = yield* workspace({
repo: input.repo,
sha: input.sha,
});
const exec = yield* sandbox.exec({
cwd: dir,
container,
env: { FAIL_ON: failOn },
// `scan` is a thin wrapper in the base image that normalizes
// each scanner's flags and exit codes against FAIL_ON.
command: ["scan", scanner],
});
const reportUri = yield* artifact.upload({
name: `${scanner}-report.json`,
path: exec.logPath,
container,
});
return { scanner, exitCode: exec.exitCode, reportUri };
}),
{ concurrency: 4 },
),
);
const failed = scannerResults.some((r) => r.exitCode !== 0);
return { failed, scannerResults };
}),
}); // Recipe: post-deploy smoke test — the `deploy-smoke` Run
//
// Re-exports the canonical run from runs/deploy-smoke.ts (the Dispatcher's
// registered implementation). Copy the runs/deploy-smoke.ts file verbatim
// into your own repo when forking this recipe — the indirection here keeps
// the recipe page in sync with the registered run without duplicating code.
//
// Mode: Webhook mode — fires on `deployment_status.success`, no GHA workflow
// file. An Action-mode alternative (./ci.yml) dispatches the same run
// for repos that cannot install the App.
// DSL: see specs/03-dsl.md.
export { deploySmoke } from "@flare-dispatch/runs/deploy-smoke"; # Recipe: post-deploy smoke test — Action-mode alternative
#
# Use case: same `deploy-smoke` run as ./smoke.run.ts, but dispatched from a
# GitHub Actions workflow instead of the FlareDispatch GitHub App webhook.
#
# Webhook mode (the `triggers` block in ./smoke.run.ts) is preferred — it
# burns zero GHA minutes and needs no workflow file. Use this Action-mode
# file when you cannot install the App, or want the smoke test to interleave
# with other jobs on the deployment.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: deploy-smoke — defined in ./smoke.run.ts.
name: deploy-smoke
on:
deployment_status:
jobs:
deploy-smoke:
# GHA cannot filter `deployment_status` by state in `on:`, so gate the job
# here. This `if:` mirrors the webhook `gate` in ./smoke.run.ts — success
# transition, production only, environment_url present.
if: >-
github.event.deployment_status.state == 'success' &&
github.event.deployment.environment == 'production' &&
github.event.deployment_status.environment_url != ''
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: deploy-smoke
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.event.deployment.sha }}",
"baseURL": "${{ github.event.deployment_status.environment_url }}",
"paths": ["/", "/health", "/api/status"]
}
# A non-zero `failed` count fails the check-run `flare-dispatch/deploy-smoke`
# on the deployed SHA — require it in branch protection, not this GHA job. // Recipe: nightly end-to-end suite
//
// The simplest Schedule-mode shape. A Cloudflare Cron Trigger fires this run
// every night; it dispatches the shipped `playwright-e2e` run against each
// long-lived environment. No GitHub event, no PR, no enumeration — the
// targets are a fixed list known at deploy time, so `schedules[].inputs`
// produces the whole input directly.
//
// Contrast with recipes/ai-code-review/pr-review-sweep.run.ts, which must
// *discover* its targets (open PRs) at run time. When the target set is
// static, Schedule mode is just a `schedules` block plus a fixed input —
// there is no `enumerate` step.
//
// Mode: Schedule mode — a Cloudflare Cron Trigger drives the Dispatcher's
// scheduled() handler. See specs/04-gha-integration.md § Schedule mode.
// DSL: `schedules` on defineRun (specs/03-dsl.md § schedules); `spawnChildRun`
// to dispatch the shipped playwright-e2e run; `sharded` to fan out.
import { Effect, Schema } from "effect";
import { defineRun, step, io, spawnChildRun } from "@flare-dispatch/core";
import { sharded } from "@flare-dispatch/core/primitives";
// The environments to exercise nightly. This list is the recipe's entire
// configuration — edit it for your deploys. `ref` is the branch whose test
// suite to run; a nightly suite tracks the branch tip, so `sandbox.git.clone`
// resolving the ref (rather than a pinned SHA) is the intended behaviour.
const ENVIRONMENTS = [
{
name: "staging",
repo: "openhackersclub/flare-dispatch",
ref: "main",
baseURL: "https://staging.example.com",
},
{
name: "canary",
repo: "openhackersclub/flare-dispatch",
ref: "main",
baseURL: "https://canary.example.com",
},
] as const;
// UTC calendar date — the cron-window dedup key.
const isoDate = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
const Input = Schema.Struct({
firedAt: Schema.Number, // controller.scheduledTime, epoch ms
shardsPerEnv: Schema.Number,
});
const Output = Schema.Struct({
environments: Schema.Number,
dispatched: Schema.Number,
});
export const nightlyE2E = defineRun({
name: "nightly-e2e",
version: "1.0.0",
// 02:00 UTC daily. This expression MUST also appear in wrangler.jsonc
// `triggers.crons` — see specs/05-byoc.md § Wrangler config.
schedules: [
{
cron: "0 2 * * *",
// One run per calendar day; a duplicate cron delivery collapses.
idempotencyKey: ({ firedAt }) => `nightly-e2e:${isoDate(firedAt)}`,
// A cron tick names no target — but here the targets are static, so
// `inputs` is the whole input. No enumeration step is needed.
inputs: ({ firedAt }) => ({ firedAt, shardsPerEnv: 4 }),
},
],
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 3000, maxConcurrency: ENVIRONMENTS.length },
run: (input) =>
Effect.gen(function* () {
yield* step("log-scope", () =>
io.log("info", `nightly-e2e: ${ENVIRONMENTS.length} environment(s)`, {
firedAt: input.firedAt,
}),
);
// Fan out the shipped `playwright-e2e` run, one child per environment.
// The list is static, so this is a plain count-and-index `sharded` over
// ENVIRONMENTS — no `github` enumeration. Each child shards the suite
// itself (`shardsPerEnv`); this run only fans out across environments.
yield* step("dispatch", () =>
sharded({
count: ENVIRONMENTS.length,
body: ({ index }) => {
const env = ENVIRONMENTS[index - 1];
return spawnChildRun({
run: "playwright-e2e",
// Semantic id — one playwright-e2e execution per env per day.
instanceId: `playwright-e2e:${env.name}:${isoDate(input.firedAt)}`,
input: {
repo: env.repo,
sha: env.ref, // branch ref — git.clone checks out the tip
baseURL: env.baseURL,
shards: input.shardsPerEnv,
},
});
},
}),
);
return {
environments: ENVIRONMENTS.length,
dispatched: ENVIRONMENTS.length,
};
}),
}); // Recipe: weekly release notes with a human gate
//
// Identical to the deployed `runs/release-notes.ts`. Drop into your repo's
// `runs/` directory; the Dispatcher auto-discovers it. See ./README.md.
//
// A Schedule-mode run that, every Monday, drafts release notes for the unreleased
// range (`<last-tag>..HEAD`), opens a release PR for review, and HIBERNATES on a
// human approval before publishing the GitHub Release. It is the canonical
// example of Schedule mode's two durable mechanisms working together:
//
// 1. a wall-clock trigger — the `schedules` block (`0 9 * * 1`); the Cron
// Trigger is the heartbeat (must also be in wrangler.jsonc `triggers.crons`);
// 2. a durable pause — `step.waitForEvent` (specs/03-dsl.md § Human-in-the-loop):
// after drafting, the Workflow sleeps for up to 72h at ZERO CPU cost,
// surviving Worker eviction, until the gate is resolved.
//
// --- The approval surface: a release PR (GitHub-native) ---------------------
//
// The run opens a NON-draft PR carrying the rendered notes + a hidden marker
// pinning its own Workflow instance id. A human approves by MERGING the PR (or
// the `release:approve` label) and rejects by CLOSING it unmerged (or
// `release:reject`). The dispatcher's webhook (apps/dispatcher/src/release-
// approval.ts) reads the marker and signals this run — so GitHub's repo
// permissions are the authZ and the decider is a real GitHub identity, no shared
// ADMIN_TOKEN. /v1/admin/events/:wf_id stays as a manual override.
//
// --- What's real (no fictional CLI) -----------------------------------------
//
// The draft is built from plain `git` in the container (the same posture as
// `spec-drift-pr`: the ONE image is used only for `git`); the notes — a semver
// bump derived from Conventional Commits + a categorized changelog — are
// rendered IN THE WORKER by the pure helpers below (unit-tested, replay-safe).
// The PR open and the publish are both `github` capability writes (the App
// installation token) — no PAT, no container `gh`, no bespoke image.
//
// Mode: Schedule mode — specs/04-gha-integration.md § Schedule mode.
import { Effect, Match, Schema } from "effect";
import { defineRun, github, io, sandbox, step } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";
// The repo this run cuts releases for. One scheduled run per release line.
const TARGET = { repo: "openhackersclub/flare-dispatch", ref: "main" } as const;
// Where the approved notes land in the repo when the release PR merges — a
// durable changelog archive, and the PR's diff.
const releaseFile = (tag: string): string => `.flare-dispatch/releases/${tag}.md`;
// The hidden marker the dispatcher's webhook reads off the PR body to resume
// THIS run on merge/label. MUST match `RELEASE_APPROVAL_MARKER` in
// apps/dispatcher/src/release-approval.ts.
const approvalMarker = (wfId: string, tag: string): string =>
`<!-- flare-dispatch:release-approval wf=${wfId} tag=${tag} -->`;
const renderPrBody = (notes: string, wfId: string, tag: string): string =>
[
`### 🚀 Release \`${tag}\` — approval gate`,
"",
"> 🤖 Drafted by `flare-dispatch/release-notes`. **Merge this PR to publish the GitHub Release**, or add the `release:reject` label / close it to cancel. (`release:approve` also publishes without merging.)",
"",
notes,
"",
approvalMarker(wfId, tag),
].join("\n");
// ISO year + week (e.g. "2026-W21") — the cron-window dedup key, so re-firing
// the same week is a no-op (04-gha-integration § Receiver dedup).
const isoYearWeek = (ms: number): string => {
const d = new Date(ms);
const thu = new Date(d);
thu.setUTCDate(d.getUTCDate() + 3 - ((d.getUTCDay() + 6) % 7));
const week1 = new Date(Date.UTC(thu.getUTCFullYear(), 0, 4));
const week =
1 +
Math.round(
((thu.getTime() - week1.getTime()) / 86_400_000 -
3 +
((week1.getUTCDay() + 6) % 7)) /
7,
);
return `${thu.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
};
// The inbound approval signal. Primary path: the dispatcher's webhook resolves
// a merge/label on the release PR and POSTs it (release-approval.ts). Fallback:
// an operator POSTs to /v1/admin/events/:wf_id. Either way the payload is this.
const ApprovalPayload = Schema.Struct({
decision: Schema.Literal("approve", "reject"),
decider: Schema.String, // GitHub login (or email, via the admin path)
});
const Input = Schema.Struct({
firedAt: Schema.Number, // epoch ms the cron fired at
});
const Output = Schema.Struct({
published: Schema.Boolean,
reason: Schema.Literal(
"published",
"rejected",
"no-changes",
"not-opened", // the App isn't configured, so no approval PR could be opened
"not-configured", // approved, but createRelease degraded to a no-op
),
tag: Schema.String,
prNumber: Schema.Number, // the approval PR ("0" when none opened)
prUrl: Schema.String,
releaseUrl: Schema.String, // the published GitHub Release URL ("" unless published)
});
// ---------------------------------------------------------------------------
// Pure helpers — Conventional-Commit parsing, semver bump, notes rendering.
// Exported for unit tests; no I/O, fully deterministic given the git log.
// ---------------------------------------------------------------------------
/** One commit parsed out of the container's `git log` output. */
export type RawCommit = {
readonly sha: string;
readonly subject: string;
readonly author: string;
};
/** What `collect-git` returns — the inputs every later step is derived from. */
export type GitState = {
readonly headSha: string;
readonly lastTag: string; // "" when the repo has no tags yet
readonly commits: readonly RawCommit[];
};
/** Field separator git emits between log fields (ASCII Unit Separator). */
const US = "";
/**
* Parse the labelled output of {@link GIT_SCRIPT}. Lines are either
* `HEAD <sha>`, `LASTTAG <tag?>`, or `COMMIT␟<sha>␟<subject>␟<author>`.
*/
export const parseGitState = (stdout: string): GitState => {
let headSha = "";
let lastTag = "";
const commits: RawCommit[] = [];
for (const line of stdout.split("\n")) {
if (line.startsWith("HEAD ")) headSha = line.slice("HEAD ".length).trim();
else if (line.startsWith("LASTTAG"))
lastTag = line.slice("LASTTAG".length).trim();
else if (line.startsWith(`COMMIT${US}`)) {
const [, sha, subject, author] = line.split(US);
if (sha !== undefined && subject !== undefined)
commits.push({ sha, subject, author: author ?? "" });
}
}
return { headSha, lastTag, commits };
};
/** A Conventional Commit, parsed from a commit subject. */
export type ConvCommit = {
/** Normalized type — `feat` / `fix` / `perf` / `docs` / … / `other`. */
readonly type: string;
readonly breaking: boolean;
/** The change description (subject minus the `type(scope)!:` prefix). */
readonly description: string;
/** PR number parsed from a trailing `(#123)`, when present. */
readonly pr?: number;
readonly author: string;
};
const CONVENTIONAL = /^(\w+)(\([^)]*\))?(!)?:\s*(.+)$/;
const TRAILING_PR = /\(#(\d+)\)\s*$/;
/** Parse a raw commit into a Conventional Commit (best-effort). */
export const parseConventional = (c: RawCommit): ConvCommit => {
const m = CONVENTIONAL.exec(c.subject);
const breaking = m?.[3] === "!" || /BREAKING[ -]CHANGE/.test(c.subject);
const type = m ? m[1]!.toLowerCase() : "other";
const description = (m ? m[4]! : c.subject).trim();
const prMatch = TRAILING_PR.exec(c.subject);
return {
type,
breaking,
description: description.replace(TRAILING_PR, "").trim(),
...(prMatch ? { pr: Number(prMatch[1]) } : {}),
author: c.author,
};
};
export type Bump = "major" | "minor" | "patch";
/** Pick the version bump implied by a set of commits. */
export const bumpFor = (commits: readonly ConvCommit[]): Bump =>
commits.some((c) => c.breaking)
? "major"
: commits.some((c) => c.type === "feat")
? "minor"
: "patch";
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
/**
* Compute the next tag from the last tag + the bump the commits imply. No prior
* tag (or an unparseable one) starts from `v0.0.0`, so the first release with a
* `feat` becomes `v0.1.0`.
*/
export const nextVersion = (
lastTag: string,
commits: readonly ConvCommit[],
): string => {
const m = SEMVER.exec(lastTag);
const [major, minor, patch] = m
? [Number(m[1]), Number(m[2]), Number(m[3])]
: [0, 0, 0];
switch (bumpFor(commits)) {
case "major":
return `v${major + 1}.0.0`;
case "minor":
return `v${major}.${minor + 1}.0`;
case "patch":
return `v${major}.${minor}.${patch + 1}`;
}
};
/** The changelog sections, in render order: [normalized type(s), heading]. */
const SECTIONS: ReadonlyArray<{ heading: string; types: readonly string[] }> = [
{ heading: "### 🚀 Features", types: ["feat"] },
{ heading: "### 🐛 Fixes", types: ["fix"] },
{ heading: "### ⚡ Performance", types: ["perf"] },
{ heading: "### 📝 Documentation", types: ["docs"] },
{
heading: "### 🧹 Other changes",
types: ["refactor", "chore", "build", "ci", "style", "test", "other"],
},
];
const prRef = (repo: string, c: ConvCommit, sha: string): string =>
c.pr !== undefined
? `[#${c.pr}](https://github.com/${repo}/pull/${c.pr})`
: `\`${sha.slice(0, 7)}\``;
const line = (repo: string, c: ConvCommit, sha: string): string => {
const who = c.author ? ` — @${c.author}` : "";
return `- ${c.description} (${prRef(repo, c, sha)})${who}`;
};
/**
* Render proper, categorized Markdown release notes — breaking changes first,
* then Features / Fixes / Performance / Documentation / Other, then a compare
* link and the contributor list. Pure and deterministic.
*/
export const renderReleaseNotes = (args: {
readonly repo: string;
readonly tag: string;
readonly lastTag: string;
readonly raw: readonly RawCommit[];
readonly date: string;
}): string => {
const { repo, tag, lastTag, raw, date } = args;
const parsed = raw.map((c) => ({ conv: parseConventional(c), sha: c.sha }));
const out: string[] = [`## ${tag} (${date})`, ""];
const compare = lastTag
? `https://github.com/${repo}/compare/${lastTag}...${tag}`
: `https://github.com/${repo}/commits/${tag}`;
out.push(
`${parsed.length} change${parsed.length === 1 ? "" : "s"} since ` +
(lastTag ? `[\`${lastTag}\`](${compare})` : "the start of the project") +
`. [Full diff](${compare}).`,
"",
);
const breaking = parsed.filter((p) => p.conv.breaking);
if (breaking.length > 0) {
out.push("### ⚠️ Breaking changes");
for (const p of breaking) out.push(line(repo, p.conv, p.sha));
out.push("");
}
for (const section of SECTIONS) {
const rows = parsed.filter(
(p) => !p.conv.breaking && section.types.includes(p.conv.type),
);
if (rows.length === 0) continue;
out.push(section.heading);
for (const p of rows) out.push(line(repo, p.conv, p.sha));
out.push("");
}
const contributors = [...new Set(raw.map((c) => c.author).filter(Boolean))];
if (contributors.length > 0) {
out.push(
`**Contributors:** ${contributors.map((a) => `@${a}`).join(", ")}`,
"",
);
}
return out.join("\n").trimEnd() + "\n";
};
// The in-container collector: HEAD sha, the most recent reachable tag, and the
// commit log for the unreleased range. `git fetch --tags` is best-effort — the
// clone may already carry tags; a fresh repo (this one, today) simply has none.
const GIT_SCRIPT = `set -e
git fetch --tags --force origin >/dev/null 2>&1 || true
last=$(git describe --tags --abbrev=0 2>/dev/null || true)
head=$(git rev-parse HEAD)
if [ -n "$last" ]; then range="$last..HEAD"; else range="HEAD"; fi
echo "HEAD $head"
echo "LASTTAG $last"
git log "$range" --no-merges --pretty=format:'COMMIT%x1f%H%x1f%s%x1f%an'`;
export const releaseNotes = defineRun({
name: "release-notes",
version: "1.0.0",
image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",
// Schedule mode: 09:00 UTC every Monday. MUST also appear in wrangler.jsonc
// `triggers.crons`.
schedules: [
{
cron: "0 9 * * 1",
idempotencyKey: ({ firedAt }) => `release-notes:${isoYearWeek(firedAt)}`,
inputs: ({ firedAt }) => ({ firedAt }),
},
],
inputs: Input,
outputs: Output,
// Wall-clock ceiling covers the 72h approval wait — the Workflow hibernates
// for almost all of it, consuming no CPU.
limits: { maxDurationSec: 4 * 24 * 3600 },
run: (input) =>
Effect.gen(function* () {
// 1. Check out the release repo (the image is used only for `git`).
const { container, dir } = yield* workspace({
repo: TARGET.repo,
sha: TARGET.ref,
});
// 2. Collect the unreleased range from plain `git`.
const git = yield* step("collect-git", () =>
sandbox
.exec({ cwd: dir, container, command: ["sh", "-lc", GIT_SCRIPT] })
.pipe(Effect.map((r) => parseGitState(r.stdout))),
);
// 3. Nothing merged since the last tag — skip the release and the gate.
if (git.commits.length === 0) {
yield* step("notify", () =>
io.log("info", "release-notes: no changes since last tag — skipping"),
);
return {
published: false as const,
reason: "no-changes" as const,
tag: git.lastTag || "v0.0.0",
prNumber: 0,
prUrl: "",
releaseUrl: "",
};
}
// 4. Compute the next tag + render proper categorized notes (pure).
const parsed = git.commits.map(parseConventional);
const tag = nextVersion(git.lastTag, parsed);
const date = new Date(input.firedAt).toISOString().slice(0, 10);
const notes = renderReleaseNotes({
repo: TARGET.repo,
tag,
lastTag: git.lastTag,
raw: git.commits,
date,
});
// 5. Open the release PR — the GitHub-native approval surface. It carries
// the notes (as a committed file + the body) and a hidden marker
// pinning THIS run's instance id, so the dispatcher's webhook can
// resume the exact paused run when a human merges or labels it. Opened
// NON-draft so "merge to publish" is one click.
const wfId = yield* io.executionId;
const pr = yield* step("open-release-pr", () =>
github.openDraftPullRequest({
repo: TARGET.repo,
baseBranch: TARGET.ref,
headBranch: `flare-dispatch/release-${tag}`,
title: `release: ${tag}`,
body: renderPrBody(notes, wfId, tag),
commitMessage: `chore(release): notes for ${tag}`,
files: [{ path: releaseFile(tag), content: notes }],
draft: false,
}),
);
// No PR opened (App not configured) — there's no surface to gate on, so
// don't hibernate forever; report and stop.
if (pr.number === 0) {
yield* step("notify-not-opened", () =>
io.log(
"warn",
`release-notes: could not open the approval PR for ${tag} (GitHub App not configured) — not gating`,
),
);
return {
published: false as const,
reason: "not-opened" as const,
tag,
prNumber: 0,
prUrl: "",
releaseUrl: "",
};
}
yield* step("announce", () =>
io.log(
"info",
`release ${tag} drafted — PR #${pr.number} awaiting approval (merge to publish)`,
{ prUrl: pr.url, changes: git.commits.length },
),
);
// 6. Hibernate until the PR is merged/labeled (webhook) or an operator
// signals via /v1/admin/events. The Workflow consumes no CPU and
// survives eviction for the full timeout window.
const approval = yield* step.waitForEvent("release approval", {
type: "release-approval",
timeout: "72 hours",
payloadSchema: ApprovalPayload,
});
// 7. Exhaustive match on the decision — a new variant becomes a compile
// error, never a silent fall-through.
return yield* Match.value(approval.decision).pipe(
Match.when("reject", () =>
Effect.succeed({
published: false as const,
reason: "rejected" as const,
tag,
prNumber: pr.number,
prUrl: pr.url,
releaseUrl: "",
}),
),
Match.when("approve", () =>
Effect.gen(function* () {
// Publishing is the `github.createRelease` capability write — the
// Dispatcher's App installation token, which also creates the tag at
// the drafted HEAD sha. On an uncredentialed deploy it degrades to a
// logged no-op (`published: false`) rather than failing the run.
const release = yield* step("publish-release", () =>
github.createRelease({
repo: TARGET.repo,
tag,
target: git.headSha,
name: tag,
body: notes,
}),
);
return {
published: release.published,
reason: release.published
? ("published" as const)
: ("not-configured" as const),
tag,
prNumber: pr.number,
prUrl: pr.url,
releaseUrl: release.url,
};
}),
),
Match.exhaustive,
);
}),
}); // Recipe: scheduled dependency audit
//
// A Schedule-mode run that audits dependencies across *every* repo the
// FlareDispatch App is installed on, every night. A vulnerability does not
// wait for your next PR — a dependency safe at merge time is disclosed days
// later. This run re-scans on a cadence so a new CVE surfaces within a day,
// not at the next push.
//
// Shape: enumerate, then fan out — the same shape as ai-code-review's
// pr-review-sweep, but the unit of work is a *repo*, not a PR. A cron tick
// names no target, so the `enumerate` step discovers them with the `github`
// capability, then the run dispatches the shipped `security-scan` run once
// per repo.
//
// Mode: Schedule mode — specs/04-gha-integration.md § Schedule mode.
// DSL: `schedules` on defineRun; `github.repositories()` to enumerate the
// installed repos (specs/03-dsl.md § github); `sharded` + step.sleepUntil
// to stagger the fan-out under the GitHub API rate limit.
import { Effect, Schema } from "effect";
import { defineRun, step, github, io, spawnChildRun } from "@flare-dispatch/core";
import { sharded } from "@flare-dispatch/core/primitives";
// The scanners every repo is audited with. `security-scan` skips a scanner
// whose ecosystem the repo doesn't use, so this list can be broad.
const SCANNERS = ["pnpm-audit", "npm-audit", "cargo-audit", "trivy-fs"] as const;
// UTC calendar date — the cron-window dedup key.
const isoDate = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
const Input = Schema.Struct({
// Coarse scope from schedules[].inputs — NOT a target.
pushedWithinDays: Schema.Number, // skip repos with no recent activity
firedAt: Schema.Number,
});
const Output = Schema.Struct({
reposFound: Schema.Number,
dispatched: Schema.Number, // child security-scan executions created
});
export const scheduledDeps = defineRun({
name: "scheduled-deps",
version: "1.0.0",
// Schedule mode: 04:00 UTC daily. Must also appear in wrangler.jsonc
// `triggers.crons` (specs/05-byoc.md § Wrangler config).
schedules: [
{
cron: "0 4 * * *",
idempotencyKey: ({ firedAt }) => `scheduled-deps:${isoDate(firedAt)}`,
inputs: ({ firedAt }) => ({ pushedWithinDays: 90, firedAt }),
},
],
inputs: Input,
outputs: Output,
// Mostly hibernating on staggered sleeps — the ceiling covers the stagger
// window plus enumeration headroom.
limits: { maxDurationSec: 7200, maxConcurrency: 100 },
run: (input) =>
Effect.gen(function* () {
// 1. Enumerate. A cron tick names no repo — discovery is the first
// step. `github.repositories` is the runtime-provided, App-token
// -backed read surface; archived repos and ones idle beyond the
// window are filtered out so the audit doesn't churn on dead code.
const repos = yield* step("enumerate", () =>
github.repositories({
includeArchived: false,
pushedWithinDays: input.pushedWithinDays,
}),
);
yield* step("log-scope", () =>
io.log("info", `dependency audit: ${repos.length} repo(s)`, {
firedAt: input.firedAt,
}),
);
// 2. Fan out the shipped `security-scan` run, one child per repo,
// staggered across 30 min so the enumeration's API budget and the
// container pool never see a burst. Each child keeps a semantic,
// date-windowed instanceId, so a duplicate cron delivery — or an
// overlapping manual scan — collapses to a no-op create.
const STAGGER_MS = 30 * 60_000;
const outcomes = yield* sharded({
count: repos.length,
concurrency: repos.length,
body: ({ index, total }) =>
Effect.gen(function* () {
const repo = repos[index - 1];
const offset = Math.floor((STAGGER_MS / Math.max(total, 1)) * (index - 1));
yield* step.sleepUntil(
`stagger-${repo.repo}`,
input.firedAt + offset,
);
return yield* step(`scan-${repo.repo}`, () =>
spawnChildRun({
run: "security-scan",
instanceId: `security-scan:${repo.repo}:${isoDate(input.firedAt)}`,
input: {
repo: repo.repo,
// Default-branch tip — a scheduled audit tracks the branch,
// so git.clone resolving the ref is intended.
sha: repo.defaultBranch,
scanners: SCANNERS,
failOn: "high",
},
}),
);
}),
});
const dispatched = outcomes.filter((o) => o.created).length;
return { reposFound: repos.length, dispatched };
}),
}); // Recipe: scheduled spec/implementation drift → draft PR
//
// Identical to the deployed `runs/spec-drift-pr.ts`. Drop into your repo's
// `runs/` directory; the Dispatcher auto-discovers it. See ./README.md.
//
// A Schedule-mode run that, every day, scans each configured repo for drift
// between its `specs/` and the implementation, and opens a DRAFT pull request
// with the reconciling spec edits. It is the unattended, cron-driven sibling of
// the `spec-drift` skill's `--apply`: the skill is invoked by hand; this run
// fires on a wall-clock cadence and files its proposal as a draft PR a human
// reviews before merge.
//
// --- Reuse: the SAME review infra as ai-code-review --------------------------
//
// The model call goes through `@flare-dispatch/review-agent`'s reusable
// `completeStructured` engine — the very `workers-ai` backend machinery
// `pr-review` uses (tools/json + auto-fallback + Schema-validated
// output), resolved from CONFIG_KV. No model API key: the Workers AI binding is
// the auth. The detection runs IN THE WORKER; the ONE container image is used
// only for `git` (checkout + reading specs/tree).
//
// --- CONFIG the operator sets (out of band) ---------------------------------
//
// CONFIG_KV spec-drift.repos comma/space-separated `owner/name` list to scan (required)
// CONFIG_KV spec-drift.base base branch to scan + open PRs against (default "main")
// CONFIG_KV spec-drift.backend "workers-ai" | "anthropic" | "bedrock" (default workers-ai)
// CONFIG_KV spec-drift.prompt (optional) override the drift-detection system prompt
// CONFIG_KV spec-drift.workers-ai.model model id — bare Workers AI catalog id, or a `deepseek/` reasoner (BYOK via AI Gateway)
// CONFIG_KV spec-drift.workers-ai.mode "tools" | "json" (default "tools"; pin "json" for reasoning models)
//
// Mode: Schedule mode — specs/04-gha-integration.md § Schedule mode. The cron
// MUST also be in wrangler.jsonc `triggers.crons`.
import { Effect, Match, Schema } from "effect";
import {
config,
defineRun,
github,
io,
sandbox,
StepFailed,
step,
type Container,
} from "@flare-dispatch/core";
import type { GitHubApiError } from "@flare-dispatch/core";
import { isoDate, parseList, workspace } from "@flare-dispatch/core/primitives";
import {
type BackendUnconfigured,
completeStructured,
type ModelCallFailed,
namespacedKey,
promptKey,
resolveBackend,
type StructuredOutputInvalid,
} from "@flare-dispatch/review-agent";
/** The config namespace — every key this run reads is `spec-drift.*`. */
const NAMESPACE = "spec-drift";
const key = namespacedKey(NAMESPACE);
const REPOS_KEY = key("repos");
const BASE_KEY = key("base");
/** Caps so a huge repo can't blow the model context window. */
const MAX_SPECS_CHARS = 40_000;
const MAX_TREE_CHARS = 12_000;
const PROPOSAL_MAX_TOKENS = 4096;
/** The model's proposed reconciliation — full new contents per spec file. */
const DriftProposal = Schema.Struct({
/** One-paragraph summary of the drift found (empty when none). */
summary: Schema.String,
/** The spec edits to commit — empty when the specs are already in sync. */
edits: Schema.Array(
Schema.Struct({
/** Repo-relative path under `specs/`. */
path: Schema.String,
/** The FULL new file content (not a patch). */
newContent: Schema.String,
/** Why this edit reconciles the drift. */
rationale: Schema.String,
}),
),
});
/** The generic default drift-detection prompt (operator-overridable). */
const DEFAULT_SPEC_DRIFT_PROMPT = `You keep a project's specs/ in sync with its implementation.
The IMPLEMENTATION is the source of truth: when a spec disagrees with the code,
the spec is what's wrong — UNLESS the spec explicitly marks a section as not yet
built (TODO / Planned / 🚧 / unchecked checkbox), which is an intentional plan
to leave alone. You are given each spec file's full content plus the repo's file
tree (and recent commit subjects) as drift signal. Report ONLY spec edits you
are confident reconcile real drift: stale prose contradicted by the tree,
references to files/paths that no longer exist, or verbose transcriptions of
code best replaced by a one-line pointer. Preserve each spec's intent, headings,
and tone. Return the FULL new content for every file you change. If nothing has
drifted, return an empty edits array. Never edit code — only specs.`;
const Input = Schema.Struct({
firedAt: Schema.Number,
});
const Output = Schema.Struct({
reposScanned: Schema.Number,
prsOpened: Schema.Number,
prsUpdated: Schema.Number,
reposClean: Schema.Number,
});
export const specDriftPr = defineRun({
name: "spec-drift-pr",
version: "1.0.0",
image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",
// Schedule mode: 05:00 UTC daily. Must also appear in wrangler.jsonc
// `triggers.crons`.
schedules: [
{
cron: "0 5 * * *",
idempotencyKey: ({ firedAt }) => `spec-drift-pr:${isoDate(firedAt)}`,
inputs: ({ firedAt }) => ({ firedAt }),
},
],
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 3600, maxConcurrency: 4 },
run: (input) =>
Effect.gen(function* () {
const day = isoDate(input.firedAt);
// 1. Scope: the operator's repo list. No enumeration — an empty list is a
// no-op (the run is a backstop, not an installation-wide crawler).
const repos = parseList(
yield* step("resolve-repos", () => config.get(REPOS_KEY)),
);
if (repos.length === 0) {
yield* step("log-empty", () =>
io.log("warn", `spec-drift-pr: ${REPOS_KEY} is unset — nothing to scan`),
);
return { reposScanned: 0, prsOpened: 0, prsUpdated: 0, reposClean: 0 };
}
const baseBranch =
(yield* step("resolve-base", () => config.get(BASE_KEY))) ?? "main";
// 2. Resolve the configurable backend (the ai-code-review machinery,
// under THIS run's `spec-drift.*` namespace). A misconfigured backend
// fails the run loudly — the operator must pin a model.
const resolved = yield* step("resolve-backend", () =>
resolveBackend((key) => config.get(key), { namespace: NAMESPACE }),
).pipe(
Effect.catchTag("BackendUnconfigured", (e) =>
Effect.fail(
new StepFailed({
step: "resolve-backend",
cause: `spec-drift backend "${e.backend}" misconfigured — set ${e.missing}`,
}),
),
),
);
const promptOverride = yield* step("resolve-prompt", () =>
config.get(promptKey(NAMESPACE)),
);
const systemPrompt = promptOverride ?? DEFAULT_SPEC_DRIFT_PROMPT;
// 3. Scan each repo. One repo's failure (model, git, GitHub) is logged and
// skipped — never poisons the sibling scans.
const outcomes = yield* Effect.forEach(
repos,
(repo) =>
scanRepo({ repo, baseBranch, day, resolved, systemPrompt }).pipe(
Effect.catchAll((err) =>
io
.log(
"warn",
`spec-drift-pr: skipped ${repo} — ${describe(err)}`,
)
.pipe(Effect.as({ opened: false, updated: false, clean: false })),
),
),
{ concurrency: 2 },
);
return {
reposScanned: repos.length,
prsOpened: outcomes.filter((o) => o.opened).length,
prsUpdated: outcomes.filter((o) => o.updated).length,
reposClean: outcomes.filter((o) => o.clean).length,
};
}),
});
// ---------------------------------------------------------------------------
type RepoOutcome = {
readonly opened: boolean;
readonly updated: boolean;
readonly clean: boolean;
};
type ScanArgs = {
readonly repo: string;
readonly baseBranch: string;
readonly day: string;
readonly resolved: { backend: string; model: string; mode: "tools" | "json" };
readonly systemPrompt: string;
};
/** Scan one repo for drift and, when found, open/update its draft PR. */
const scanRepo = (args: ScanArgs) =>
Effect.gen(function* () {
const { container, dir } = yield* step(`checkout-${args.repo}`, () =>
workspace({ repo: args.repo, sha: args.baseBranch }),
);
// Gather the drift inputs with plain `git` (no extra CLI in the image).
const specsText = yield* step(`gather-specs-${args.repo}`, () =>
shOut(container, dir, SPECS_SCRIPT).pipe(
Effect.map((s) => s.slice(0, MAX_SPECS_CHARS)),
),
);
if (specsText.trim().length === 0) {
// No specs/ dir → nothing to drift from.
return { opened: false, updated: false, clean: true } satisfies RepoOutcome;
}
const tree = yield* step(`gather-tree-${args.repo}`, () =>
shOut(container, dir, TREE_SCRIPT).pipe(
Effect.map((s) => s.slice(0, MAX_TREE_CHARS)),
),
);
const recentLog = yield* step(`gather-log-${args.repo}`, () =>
shOut(container, dir, LOG_SCRIPT),
);
// The reusable structured-output engine — same backend machinery as pr-review.
const proposal = yield* step(`detect-${args.repo}`, () =>
completeStructured({
backend: args.resolved.backend,
model: args.resolved.model,
mode: args.resolved.mode,
system: args.systemPrompt,
userBody: renderUserBody({ repo: args.repo, specsText, tree, recentLog }),
jsonContract: DRIFT_JSON_CONTRACT,
schema: DriftProposal,
toolName: "propose_spec_edits",
toolDescription:
"Propose the spec-file edits that reconcile drift (possibly none).",
surface: "spec-drift",
maxTokens: PROPOSAL_MAX_TOKENS,
}),
);
if (proposal.edits.length === 0) {
yield* io.log("info", `spec-drift-pr: ${args.repo} — specs in sync`);
return { opened: false, updated: false, clean: true } satisfies RepoOutcome;
}
const result = yield* step(`open-pr-${args.repo}`, () =>
github.openDraftPullRequest({
repo: args.repo,
baseBranch: args.baseBranch,
headBranch: `flare-dispatch/spec-drift-${args.day}`,
title: `docs(specs): reconcile spec drift (${args.day})`,
body: renderPrBody(proposal),
commitMessage: `docs(specs): reconcile spec/implementation drift\n\nGenerated by flare-dispatch spec-drift-pr.`,
files: proposal.edits.map((e) => ({
path: e.path,
content: e.newContent,
})),
}),
);
yield* io.log(
"info",
`spec-drift-pr: ${args.repo} — ${result.created ? "opened" : "updated"} draft PR #${result.number}`,
);
return {
opened: result.created,
updated: !result.created,
clean: false,
} satisfies RepoOutcome;
});
// --- In-container gather scripts (plain `git`, no extra CLI) -----------------
/** Concatenate every tracked spec markdown with a path delimiter. */
const SPECS_SCRIPT = `for f in $(git ls-files 'specs/*.md' 'specs/**/*.md' 2>/dev/null); do printf '\\n===FILE %s===\\n' "$f"; cat "$f"; done`;
/** The repo's tracked file tree (drift signal: which paths actually exist). */
const TREE_SCRIPT = `git ls-files | head -800`;
/** Recent commit subjects (drift signal: what changed lately). */
const LOG_SCRIPT = `git log --oneline -n 25`;
/** Run a `sh -lc <script>` in the container and return stdout (best-effort). */
const shOut = (container: Container, cwd: string, script: string) =>
sandbox
.exec({ container, cwd, command: ["sh", "-lc", script] })
.pipe(Effect.map((r) => r.stdout));
// --- Prompt + PR rendering ---------------------------------------------------
/** The compact JSON shape the model must emit (engine appends it in json mode). */
const DRIFT_JSON_CONTRACT = `{"summary":string,"edits":[{"path":string,"newContent":string,"rationale":string}]}`;
/** The domain body of the user message (the engine appends the per-mode framing). */
const renderUserBody = (ctx: {
repo: string;
specsText: string;
tree: string;
recentLog: string;
}): string =>
[
`Repository: ${ctx.repo}`,
"",
"## specs/ (full contents)",
ctx.specsText,
"",
"## Repo file tree (tracked paths)",
ctx.tree,
"",
"## Recent commits",
ctx.recentLog,
].join("\n");
const MARKER = "<!-- flare-dispatch: spec-drift-pr -->";
const renderPrBody = (
proposal: typeof DriftProposal.Type,
): string =>
[
"### Spec drift — proposed reconciliation",
"",
"> 🤖 Draft opened by `flare-dispatch/spec-drift-pr`. The implementation is the source of truth; these edits bring the specs back in line. Review before merging.",
"",
proposal.summary.trim().length > 0 ? proposal.summary.trim() : "_See per-file rationale below._",
"",
"#### Edits",
...proposal.edits.map((e) => `- \`${e.path}\` — ${e.rationale}`),
"",
MARKER,
].join("\n");
/** The errors `scanRepo`'s `catchAll` knows how to describe precisely. */
type CaughtError =
| BackendUnconfigured
| ModelCallFailed
| StructuredOutputInvalid
| GitHubApiError;
/** Human-readable one-liner for any caught error (model / git / GitHub). */
const describe = (err: unknown): string =>
Match.value(err as CaughtError).pipe(
Match.tag(
"BackendUnconfigured",
(e) => `backend "${e.backend}" misconfigured — set ${e.missing}`,
),
Match.tag(
"ModelCallFailed",
(e) => `model call failed (${e.reason}): ${e.message}`,
),
Match.tag(
"StructuredOutputInvalid",
(e) => `unparseable model output (${e.reason})`,
),
Match.tag("GitHubApiError", (e) => `GitHub API ${e.status} (${e.reason})`),
// Sandbox/git errors and anything else fall here — every tagged error
// extends `Error`, so its `message` is a faithful one-liner.
Match.orElse(() =>
err instanceof Error ? err.message : JSON.stringify(err),
),
); // Recipe: scheduled CI-failure triage → draft PR
//
// Identical to the deployed `runs/ci-triage-pr.ts`. Drop into your repo's
// `runs/` directory; the Dispatcher auto-discovers it. See ./README.md.
//
// A Schedule-mode run that, every day, reads recent CI failures across GitHub
// Actions AND Cloudflare (Pages) deployments, asks a model to triage them, and
// opens ONE draft pull request carrying a triage write-up
// (`.flare-dispatch/ci-triage-<date>.md`) — a diagnosis + suggested next steps a
// human reviews. It does not attempt an auto-fix (that is a larger, lower-
// confidence problem); the value is a single daily, deduped, model-written
// triage of what's red, filed where the team will see it.
//
// --- Reuse: the SAME review infra as ai-code-review --------------------------
//
// The triage model call goes through `@flare-dispatch/review-agent`'s reusable
// `completeStructured` engine — the `workers-ai` backend machinery
// `pr-review` uses, resolved from CONFIG_KV under this run's `ci-triage.*`
// namespace. No model API key (the Workers AI binding is the auth). Reading the
// failures uses the `github.actionRuns` + `cloudflare.deployments` read
// capabilities; opening the PR uses `github.openDraftPullRequest`. No container.
//
// --- CONFIG the operator sets (out of band) ---------------------------------
//
// CONFIG_KV ci-triage.repos comma/space-separated `owner/name` list to scan Actions on (required)
// CONFIG_KV ci-triage.projects (optional) Cloudflare Pages project names to scan deploys on
// CONFIG_KV ci-triage.report-repo repo to open the triage PR on (default: first of ci-triage.repos)
// CONFIG_KV ci-triage.base base branch for the triage PR (default "main")
// CONFIG_KV ci-triage.window-hours only failures newer than this (default 24)
// CONFIG_KV ci-triage.backend "workers-ai" | "anthropic" | "bedrock" (default workers-ai)
// CONFIG_KV ci-triage.prompt (optional) override the triage system prompt
// CONFIG_KV ci-triage.workers-ai.model model id — catalog id or `deepseek/` reasoner (+ .workers-ai.mode "tools"|"json", default "tools")
//
// Mode: Schedule mode — specs/04-gha-integration.md § Schedule mode. The cron
// MUST also be in wrangler.jsonc `triggers.crons`.
//
// ALSO Action-mode dispatchable (`POST /v1/dispatch/ci-triage-pr`): a consumer
// that owns its own scheduling can dispatch daily with optional caller-supplied
// `signals` — observability errors collected from systems the dispatcher's read
// capabilities don't reach (APM/tracing SaaS, Workers runtime exception logs,
// health probes). Signals are folded into the same triage + report, and count
// as failures for the green-day check.
import { Effect, Schema } from "effect";
import {
cloudflare,
config,
defineRun,
github,
io,
SignalArray,
StepFailed,
step,
type DeploymentRef,
type SignalT,
type WorkflowRunRef,
} from "@flare-dispatch/core";
import { isoDate, parseList } from "@flare-dispatch/core/primitives";
import {
completeStructured,
namespacedKey,
promptKey,
resolveBackend,
} from "@flare-dispatch/review-agent";
const NAMESPACE = "ci-triage";
const key = namespacedKey(NAMESPACE);
const REPOS_KEY = key("repos");
const PROJECTS_KEY = key("projects");
const REPORT_REPO_KEY = key("report-repo");
const BASE_KEY = key("base");
const WINDOW_KEY = key("window-hours");
const DEFAULT_WINDOW_HOURS = 24;
const TRIAGE_MAX_TOKENS = 3072;
// The caller-supplied observability signal contract (`signals/v1`) — the
// `Signal` shape, `SignalArray` (the capped array runs accept), and the caps —
// is the canonical `@flare-dispatch/core` contract. This run is one consumer
// of it; see packages/core/src/signals.ts and specs/02-runs.md § Signals.
/** The model's triage of the day's failures. */
const TriageReport = Schema.Struct({
/** A 1–2 sentence overview of the day's CI health. */
summary: Schema.String,
/** One triage item per distinct failure cluster. */
items: Schema.Array(
Schema.Struct({
/** Short title naming the failure. */
title: Schema.String,
/** Where it failed — e.g. "github-actions" | "cloudflare-pages". */
area: Schema.String,
/** The most likely root cause, in one or two sentences. */
diagnosis: Schema.String,
/** A concrete suggested next step. */
suggestedFix: Schema.String,
}),
),
});
const DEFAULT_TRIAGE_PROMPT = `You triage continuous-integration failures.
You are given a list of recently-failed GitHub Actions workflow runs and
Cloudflare Pages deployments (repo / workflow / conclusion / branch / URL).
Cluster related failures, and for each cluster give a short title, the area it
failed in, the most likely root cause, and a concrete suggested next step.
Prefer a few high-signal items over many shallow ones. Be specific and
actionable; do not invent failures that aren't in the list. This is a triage
write-up for humans, not an automated fix.`;
const Input = Schema.Struct({
firedAt: Schema.Number,
/**
* Optional caller-supplied observability signals (Action-mode dispatch).
* Schedule mode sends none — the Workflow's decode defaults to `[]`.
*/
signals: Schema.optionalWith(SignalArray, {
default: () => [],
}),
});
const Output = Schema.Struct({
actionsFailures: Schema.Number,
deployFailures: Schema.Number,
signalsCount: Schema.Number,
prOpened: Schema.Boolean,
prUpdated: Schema.Boolean,
prNumber: Schema.Number,
});
export const ciTriagePr = defineRun({
name: "ci-triage-pr",
version: "1.0.0",
image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",
// Schedule mode: 06:00 UTC daily. Must also appear in wrangler.jsonc
// `triggers.crons`.
schedules: [
{
cron: "0 6 * * *",
idempotencyKey: ({ firedAt }) => `ci-triage-pr:${isoDate(firedAt)}`,
// Cron has no caller to supply signals — schedule days triage only what
// the read capabilities see.
inputs: ({ firedAt }) => ({ firedAt, signals: [] }),
},
],
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1800, maxConcurrency: 4 },
run: (input) =>
Effect.gen(function* () {
const day = isoDate(input.firedAt);
// Direct `run()` callers (tests) may bypass the Workflow's Schema decode
// that applies the `[]` default — normalize here.
const signals = input.signals ?? [];
const empty = {
actionsFailures: 0,
deployFailures: 0,
signalsCount: signals.length,
prOpened: false,
prUpdated: false,
prNumber: 0,
};
// 1. Scope from config.
const repos = parseList(yield* step("resolve-repos", () => config.get(REPOS_KEY)));
const projects = parseList(
yield* step("resolve-projects", () => config.get(PROJECTS_KEY)),
);
if (repos.length === 0 && projects.length === 0 && signals.length === 0) {
yield* step("log-empty", () =>
io.log(
"warn",
`ci-triage-pr: neither ${REPOS_KEY} nor ${PROJECTS_KEY} is set and the dispatch carried no signals — nothing to triage`,
),
);
return empty;
}
const windowHours =
Number.parseInt(
(yield* step("resolve-window", () => config.get(WINDOW_KEY))) ?? "",
10,
) || DEFAULT_WINDOW_HOURS;
const baseBranch =
(yield* step("resolve-base", () => config.get(BASE_KEY))) ?? "main";
const reportRepo =
(yield* step("resolve-report-repo", () => config.get(REPORT_REPO_KEY))) ??
repos[0];
if (reportRepo === undefined) {
// CF projects configured but no repo to open the triage PR on.
yield* step("log-no-report-repo", () =>
io.log(
"warn",
`ci-triage-pr: no ${REPORT_REPO_KEY} and no ${REPOS_KEY} — nowhere to open the triage PR`,
),
);
return empty;
}
// 2. Read the failures (read capabilities — github.actionRuns +
// cloudflare.deployments). Both degrade to empty when uncredentialed.
const actionFailures =
repos.length === 0
? []
: yield* step("read-actions", () =>
github.actionRuns({
repos,
status: "completed",
conclusion: "failure",
createdWithinHours: windowHours,
}),
);
const deployFailures =
projects.length === 0
? []
: yield* step("read-deploys", () =>
cloudflare.deployments({
projects,
status: "failure",
createdWithinHours: windowHours,
}),
);
// Signals count as failures for the green-day check: a day with zero
// CI/deploy failures but real caller-observed runtime errors must still
// open the triage PR.
if (actionFailures.length === 0 && deployFailures.length === 0 && signals.length === 0) {
yield* step("log-green", () =>
io.log(
"info",
"ci-triage-pr: no CI failures or signals in window — nothing to triage",
),
);
return empty;
}
// 3. Resolve the configurable backend (the ai-code-review machinery, under
// this run's `ci-triage.*` namespace).
const resolved = yield* step("resolve-backend", () =>
resolveBackend((key) => config.get(key), { namespace: NAMESPACE }),
).pipe(
Effect.catchTag("BackendUnconfigured", (e) =>
Effect.fail(
new StepFailed({
step: "resolve-backend",
cause: `ci-triage backend "${e.backend}" misconfigured — set ${e.missing}`,
}),
),
),
);
const systemPrompt =
(yield* step("resolve-prompt", () => config.get(promptKey(NAMESPACE)))) ??
DEFAULT_TRIAGE_PROMPT;
// 4. Triage via the reusable structured-output engine. A model failure is
// a real failure for a triage run (its whole job is the write-up), so
// it fails the run honestly as a StepFailed.
const report = yield* step("triage", () =>
completeStructured({
backend: resolved.backend,
model: resolved.model,
mode: resolved.mode,
system: systemPrompt,
userBody: renderUserBody({ actionFailures, deployFailures, signals }),
jsonContract: TRIAGE_JSON_CONTRACT,
schema: TriageReport,
toolName: "report_triage",
toolDescription: "Report the triage of the day's CI failures.",
surface: "ci-triage",
maxTokens: TRIAGE_MAX_TOKENS,
}),
).pipe(
Effect.catchTags({
ModelCallFailed: (e) =>
Effect.fail(
new StepFailed({ step: "triage", cause: `model call failed: ${e.message}` }),
),
StructuredOutputInvalid: (e) =>
Effect.fail(
new StepFailed({ step: "triage", cause: `unparseable triage output: ${e.message}` }),
),
}),
);
// 5. Open/update the single daily triage draft PR.
const result = yield* step("open-pr", () =>
github.openDraftPullRequest({
repo: reportRepo,
baseBranch,
headBranch: `flare-dispatch/ci-triage-${day}`,
title: `chore(ci): triage ${day} CI failures`,
body: renderPrBody(report, actionFailures.length, deployFailures.length, signals.length),
commitMessage: `chore(ci): CI failure triage for ${day}\n\nGenerated by flare-dispatch ci-triage-pr.`,
files: [
{
path: `.flare-dispatch/ci-triage-${day}.md`,
content: renderReportFile(report, actionFailures, deployFailures, signals, day),
},
],
}),
);
yield* io.log(
"info",
`ci-triage-pr: ${result.created ? "opened" : "updated"} draft PR #${result.number} on ${reportRepo}`,
);
return {
actionsFailures: actionFailures.length,
deployFailures: deployFailures.length,
signalsCount: signals.length,
prOpened: result.created,
prUpdated: !result.created,
prNumber: result.number,
};
}),
});
// --- Prompt + report rendering ----------------------------------------------
/** The compact JSON shape the model must emit (engine appends it in json mode). */
const TRIAGE_JSON_CONTRACT = `{"summary":string,"items":[{"title":string,"area":string,"diagnosis":string,"suggestedFix":string}]}`;
const failureLines = (
actions: readonly WorkflowRunRef[],
deploys: readonly DeploymentRef[],
): string => {
const a = actions.map(
(r) =>
`- [github-actions] ${r.repo} · "${r.name}" on ${r.headBranch} → ${r.conclusion} (${r.url})`,
);
const d = deploys.map(
(x) =>
`- [cloudflare-pages] ${x.project} · ${x.environment} on ${x.branch} → ${x.status} (${x.url})`,
);
return [...a, ...d].join("\n");
};
/** One line per caller-supplied signal, mirroring `failureLines`' shape. */
const signalLines = (signals: readonly SignalT[]): string =>
signals
.map((s) => {
const count = s.count !== undefined ? ` ×${s.count}` : "";
const url = s.url !== undefined ? ` (${s.url})` : "";
return `- [${s.source}] ${s.title}${count}${url}\n ${s.detail}`;
})
.join("\n");
/** The domain body of the user message (the engine appends the per-mode framing). */
const renderUserBody = (ctx: {
actionFailures: readonly WorkflowRunRef[];
deployFailures: readonly DeploymentRef[];
signals: readonly SignalT[];
}): string =>
[
"Recent CI failures to triage:",
"",
failureLines(ctx.actionFailures, ctx.deployFailures),
...(ctx.signals.length > 0
? ["", "Observability signals to triage (caller-supplied):", "", signalLines(ctx.signals)]
: []),
].join("\n");
const MARKER = "<!-- flare-dispatch: ci-triage-pr -->";
const renderPrBody = (
report: typeof TriageReport.Type,
actions: number,
deploys: number,
signals: number,
): string =>
[
`### CI triage — ${actions} Actions + ${deploys} deploy failure(s)${signals > 0 ? ` + ${signals} signal(s)` : ""}`,
"",
"> 🤖 Draft opened by `flare-dispatch/ci-triage-pr`. A model-written triage of recent CI failures — review the suggested next steps; this is a diagnosis, not an automated fix.",
"",
report.summary.trim(),
"",
"#### Items",
...report.items.map((i) => `- **${i.title}** (${i.area}) — ${i.diagnosis}`),
"",
`Full triage committed to \`.flare-dispatch/ci-triage-*.md\`.`,
"",
MARKER,
].join("\n");
const renderReportFile = (
report: typeof TriageReport.Type,
actions: readonly WorkflowRunRef[],
deploys: readonly DeploymentRef[],
signals: readonly SignalT[],
day: string,
): string =>
[
`# CI failure triage — ${day}`,
"",
report.summary.trim(),
"",
"## Triage",
...report.items.flatMap((i) => [
`### ${i.title}`,
`- **Area:** ${i.area}`,
`- **Diagnosis:** ${i.diagnosis}`,
`- **Suggested fix:** ${i.suggestedFix}`,
"",
]),
"## Raw failures",
failureLines(actions, deploys),
...(signals.length > 0 ? ["", "## Raw signals", signalLines(signals)] : []),
"",
].join("\n"); pr-review reads every diff in-Worker — single- or multi-agent — and posts one verdict with a summary table, per-finding headings, and blob links. Re-reviews auto-resolve what you've fixed.
A run returns a changed-files manifest from its credential-free container; the Dispatcher validates it and opens a PR. Best-effort — a failure annotates the green check, never flips it red.
Route model calls to Workers AI (incl. DeepSeek reasoners), Anthropic, or Bedrock — Bedrock via OIDC → STS → SigV4, no long-lived AWS keys. Swap the backend from KV config, no redeploy.
A global FIFO queue caps in-flight runs at the pool size — work beyond capacity drains in order instead of erroring. Per-container leases serialize same-container runs.
product-demo drives a deployed app over CDP with native session recording; reviewers replay it in an rrweb player, linked from the check summary and the result email.
Install the App, set one secret, and its webhook deliveries fire runs on every PR — no .github/workflows file, no GHA minutes, no HMAC to rotate. Onboarding a repo is just installing the App.
ci-triage-pr never queries your APM. A tiny consumer-side adapter (Datadog, SigNoz, HyperDX, or your own) prints the normalized signals/v1 shape — any vendor works on day one, no observability key in the Dispatcher.
A run can email a completion summary over Cloudflare Email Routing, rrweb replay one click away. Opt-in and default-secure: a no-op until send_email is configured, with an optional recipient allowlist.
FlareDispatch reviews its own pull requests and records its own walkthroughs. Each artifact below is the real output of a run on OpenHackersClub/flare-dispatch — two of the recipes shown above, fired on this codebase.
pr-review reads every file in the diff in-Worker, dedups across domain reviewers, and posts one verdict comment — a severity summary table, per-finding headings, and GitHub blob links — on every PR to this repo. This is a real review it left on the PR that added this very section.
product-demo drives the deployed app over CDP with native session recording, stitches the per-chapter frames into one ≤10 MB walkthrough GIF, and posts it inline on the PR — alongside an rrweb replay linked from the check run.
Progressive offloading.
Move CI to Cloudflare one job at a time — the heaviest, most expensive jobs first. GitHub Actions keeps the cheap jobs and the PR gate; nothing is all-or-nothing.
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.
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 ship at HEAD and are yours to fork.