DSL
03 — DSL
Runs are Effect-TS programs. The DSL is a small surface — defineRun, step, and the run-author capability namespaces (sandbox, browser, cache, artifact, io, config, github, cloudflare, oidc, modelGateway, email, childRuns) — wired together by Effect Layers so the same run code executes against the live CF stack, against wrangler dev locally, and against in-memory test fakes. github ships a narrow partially-live surface (see § github); cloudflare is a read-only window into the account’s Pages deployments (see § cloudflare); the dispatcher’s check-run callback rides on a runtime-only checks service that run bodies never see directly.
The layering: capabilities, primitives, recipes
The DSL is not one flat surface. It is three tiers, each built from the one below, and the distinction is what keeps run code small:
flowchart TB
subgraph L0[Effect-TS]
EFF[Schema · Layer · tagged errors · Schedule]
end
subgraph L1[Capabilities — @flare-dispatch/core]
CAP["sandbox · browser · cache · artifact · io · config<br/>github · cloudflare · oidc · modelGateway · email · childRuns"]
end
subgraph L2[Primitives — @flare-dispatch/core/primitives]
PRIM["workspace · installCached · loadSecrets<br/>sharded · bootApp · probeHttp"]
end
subgraph L3[Recipes — your repo]
REC["pr-review · playwright-e2e · cdp-acceptance<br/>matrix-fanout · security-scan · deploy-smoke"]
end
EFF --> CAP
CAP --> PRIM
PRIM --> REC
CAP -.->|escape hatch| REC- Capabilities are the atomic, side-effectful surface — one
Context.Tagservice per namespace, each backed by a swappable Layer (real / dev / test). A capability does one thing: launch a container, exec a command, upload a blob. It has no opinion about how CI work is shaped. - Primitives are reusable Effect-TS compositions the DSL ships on top of capabilities —
workspace,installCached,loadSecrets,sharded,bootApp,probeHttp. Every recipe was re-deriving the same checkout → install → fan-out → upload shapes by hand; a primitive is that shape, named once, typed once, tested once. This is the DX layer: the DSL “creates primitives” so a recipe author rides them instead of rebuilding them. Primitives are still just Effects — composable, layer-swappable, unit-testable — see § Primitives. - Recipes are
defineRunprograms that ride on primitives and carry only the logic unique to that CI use case. A recipe drops to raw capabilities only for the part no primitive covers — the escape hatch is always open, but the common path is a primitive call.
The rest of this spec walks the tiers bottom-up: defineRun / step (the run frame), the capability namespaces, then the primitives built from them. The worked recipes that ride on this stack are in recipes/. How each tier is shipped — @flare-dispatch/core as a pinned library, recipes as copy-paste — and what that split means for the supply chain is § Distribution and supply chain.
Terminology. “Primitive” in this spec always means a DSL primitive (this layering). The Cloudflare building blocks a run consumes — Workers, Workflows, Containers, R2, D1 — are called platform primitives to keep the two apart; 02-runs tags each run with the platform primitives it touches.
Why Effect-TS and not YAML / a custom config schema
- Typed inputs and outputs end-to-end.
Schemadefines the contract; TypeScript checks the run body against it. Misspell a field and the build fails — not on shard 5 at 2am. - Tagged errors per failure mode.
CheckoutFailed,InstallTimeout,BrowserCrashed,CacheMissare distinct types.Effect.catchTagrecovers selectively; everything else propagates with full cause information. - Retry policies as data.
Effect.retry(schedule)withSchedule.exponential+Schedule.recurs+Schedule.whileInputcomposes retry behavior without hand-rolled loops. - Layers swap implementations. The same run executes against real Sandbox in prod and against an in-process container fake in unit tests — runs become directly testable without spinning up CF.
- No YAML escaping hell. Multi-line scripts, JSON inputs, and template strings live in TypeScript, not stringly-typed config.
Top-level shape
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, cache, artifact } from "@flare-dispatch/core";
export const offloadTest = defineRun({
name: "offload-test",
version: "1.0.0",
inputs: Schema.Struct({
repo: Schema.String,
sha: Schema.String,
command: Schema.String,
timeoutSec: Schema.optional(Schema.Number),
}),
outputs: Schema.Struct({
exitCode: Schema.Number,
durationMs: Schema.Number,
logUri: Schema.String,
}),
limits: {
maxDurationSec: 1800,
},
run: (input) =>
Effect.gen(function* () {
const repoDir = yield* step("checkout", () =>
sandbox.git.clone({ repo: input.repo, sha: input.sha }),
);
const result = yield* step("exec", () =>
sandbox.exec({
cwd: repoDir,
command: input.command,
timeoutSec: input.timeoutSec ?? 600,
}),
);
const logUri = yield* step("upload-log", () =>
artifact.upload({
name: "step.log",
path: result.logPath,
signedUrlTTL: "30 days",
}),
);
return {
exitCode: result.exitCode,
durationMs: result.durationMs,
logUri,
};
}),
});
A run is just an object. The run function is an Effect that, when executed by the runtime, produces the typed output.
defineRun
declare const defineRun: <I, O, IEnc, OEnc>(
spec: {
name: string;
version: string;
image?: string; // default container image
inputs: Schema.Schema<I, IEnc>;
outputs: Schema.Schema<O, OEnc>;
limits: RunLimits;
triggers?: readonly TriggerSpec<I>[]; // Webhook-mode trigger config
schedules?: readonly ScheduleSpec<I>[]; // Schedule-mode trigger config
run: (input: I) => Effect.Effect<O, RunError, RunContext>;
},
) => Run<I, O>;
defineRun is a passive constructor — it validates the spec at module load and registers it for discovery. It doesn’t bind to any runtime; the same Run value is portable.
triggers and schedules are both optional and select how the run is fired. A run with neither is dispatch-only (Action mode). A run may declare both — fire on every PR push and sweep nightly — and run is always required regardless.
triggers — Webhook-mode trigger config
triggers is consumed only in Webhook mode: the receiver evaluates every run’s triggers on each GitHub App webhook delivery (see 04-gha-integration § Webhook mode).
// A single Webhook-mode trigger binding.
type TriggerSpec<I> = {
event: string; // GitHub webhook event, e.g. "pull_request"
actions?: readonly string[]; // event-action filter, e.g. ["opened", "synchronize"]
idempotencyKey: (ctx: { payload: WebhookPayload }) => string; // semantic dedup key
gate?: (ctx: { payload: WebhookPayload }) => boolean; // receiver-side cost gate
inputs: (ctx: { payload: WebhookPayload }) => I; // map the payload to run inputs
};
schedules — Schedule-mode trigger config
schedules is consumed only in Schedule mode: a Cloudflare Cron Trigger invokes the Dispatcher’s scheduled() handler, which routes the firing controller.cron to whichever runs declared that expression (see 04-gha-integration § Schedule mode).
// The context a cron tick provides — no GitHub payload exists.
type ScheduleContext = {
cron: string; // the expression that fired (controller.cron)
firedAt: number; // controller.scheduledTime, ms epoch
};
// A single Schedule-mode trigger binding.
type ScheduleSpec<I> = {
cron: string; // CF cron expression; must also be in wrangler triggers.crons
idempotencyKey: (ctx: ScheduleContext) => string; // collapses duplicate cron deliveries
gate?: (ctx: ScheduleContext) => boolean; // receiver-side skip (freeze window, holiday)
inputs: (ctx: ScheduleContext) => I; // coarse scope — NOT a concrete target
};
ScheduleSpec deliberately mirrors TriggerSpec, with one structural difference: a cron tick carries no WebhookPayload, so every callback receives ScheduleContext instead. The consequence is that inputs cannot name a concrete target — there is no repo, no SHA, no PR in a clock tick. It returns a scope (e.g. { scope: "open-prs", staleAfterHours: 24 }); the run’s first step performs the actual enumeration — listing installations and open PRs via the App JWT, then fanning out one child execution per target. The worked recipe is recipes/ai-code-review § Scheduled sweep. Threading a repo through ScheduleSpec.inputs fights the model — enumeration belongs in the run body, where it can do I/O and be checkpointed.
step
Wraps an Effect in a Workflow checkpoint. Each step call becomes a WorkflowStep.do(...) in the underlying CF Workflow — durable, retryable, and individually logged.
declare const step: <A, E>(
name: string,
body: () => Effect.Effect<A, E, RunContext>,
opts?: {
retries?: number; // platform-level retry on infra failure
timeoutSec?: number;
metadata?: Record<string, unknown>; // attached to the step record in D1
},
) => Effect.Effect<A, E | StepFailed, RunContext>;
Steps are the only durable boundary. Inside a step, Effects compose freely without persistence — checkpoints happen at step exit, not at every yield*. This matches Workflow semantics: step is the atom of retry.
Rules:
- A step body must be deterministic given its inputs and prior outputs. Non-determinism (random IDs, current time, env reads) goes through
io.now()/io.uuid()/io.env()so the runtime can replay it from the checkpoint. - Step names must be unique within a run — they’re the dedup key for checkpoint replay.
- A step that needs to retry on a specific error catches with
Effect.catchTaginside the step body and re-fails as a different tag (or recovers). Platform-level retry handles transient infra errors only.
The runEffect boundary shim — Workflows vs. Effect error handling
CF Workflows is imperative: its run(event, step) method awaits step.do(...) as Promises and expects thrown errors to fail a step (so the platform can record the failure and retry). Effect is functional: errors live in the typed E channel via Effect.fail, and throw inside Effect.gen is an anti-pattern.
step reconciles these two worlds with a runEffect shim. Inside a step body, the Effect rules still apply — yield* Effect.fail(new TaggedError({...})), never throw. At the step boundary, the shim executes the Effect, catches a typed failure, and rethrows it as a serializable value so Workflows can record the failure in its retry telemetry.
// packages/core/src/step.ts (sketch)
import { Effect, Cause, Exit, Option } from "effect";
export const runEffect = <A, E>(eff: Effect.Effect<A, E, RunContext>) =>
Effect.runPromiseExit(eff).then((exit) =>
Exit.match(exit, {
onSuccess: (a) => a,
onFailure: (cause) => {
// Tagged failures bubble as throws so Workflows fails the step.
// The Cause is preserved on the thrown error for OTel + check-run summary.
// `Cause.failureOption` is an Option — branch it with `Option.match`,
// never a raw `._tag` read: `Some` is a typed run failure, `None` is a
// defect (no typed failure), rendered from the pretty Cause.
const err = Option.match(Cause.failureOption(cause), {
onSome: (failure) => failure,
onNone: () => new Error(Cause.pretty(cause)),
});
// @ts-expect-error attach cause for downstream serialization
err.cause = cause;
throw err;
},
}),
);
Run authors don’t call runEffect directly — step(name, body) does it under the hood. The shim is documented because: (a) anyone writing a new platform primitive (a new Context.Tag service) needs to know the boundary contract; (b) the “throw at the Workflow boundary, never throw inside an Effect” rule is easy to invert if you don’t see the seam. The canonical reference impl lives in packages/core/src/step.ts.
Human-in-the-loop with step.waitForEvent
Status: live at HEAD.
step.waitForEventis wired inpackages/core/src/step.ts— it delegates to the ambientStepRunner.waitForEvent(the inline runner reads from a test-supplied event queue;StepRunnerCloudflarewraps the CFWorkflowStep.waitForEventAPI). The signalling route is shipped too — the Dispatcher’s/v1/admin/events/:wf_idhandler forwards{type, payload}to the named Workflow viaenv.RUNS_WORKFLOW.get(wfId).sendEvent(...). The route is opt-in: a deploy withoutADMIN_TOKENreturns503on it. Receiver-level approval dedup on(wf_id, decider_email)is not yet implemented — it lands with the first shipped run that useswaitForEvent.
Some runs pause for a human signal: release approval, manual gate before promoting to prod, “click here to ack the diff before applying the migration.” CF Workflows supports this natively via step.waitForEvent — the Workflow hibernates until an external POST to env.RUNS_WORKFLOW.get(wfId).sendEvent({ type, payload }) arrives, or until a timeout fires.
The DSL exposes this as step.waitForEvent directly:
declare const waitForEvent: <P>(
name: string,
opts: {
type: string; // event discriminator
timeout: Duration | string; // e.g. "72 hours"
payloadSchema: Schema.Schema<P, unknown>; // decodes the inbound payload
},
) => Effect.Effect<P, ApprovalTimedOut | EventPayloadInvalid, RunContext>;
Usage:
import { Effect, Match, Schema } from "effect";
import { defineRun, step, artifact, io } from "@flare-dispatch/core";
const ApprovalPayload = Schema.Struct({
decision: Schema.Literal("approve", "reject"),
deciderEmail: Schema.String,
});
export const releaseNotes = defineRun({
name: "release-notes",
// Schedule mode: a cron tick drafts the notes every Monday 09:00 UTC; the
// run then hibernates on step.waitForEvent until a human approves. The
// idempotencyKey is the ISO-week window — re-firing the same week is a
// no-op (see 04-gha-integration § Receiver dedup).
schedules: [
{
cron: "0 9 * * 1",
idempotencyKey: ({ firedAt }) => `release-notes:${isoYearWeek(firedAt)}`,
inputs: ({ firedAt }) => ({ since: firedAt - 7 * 86_400_000 }),
},
],
// ...inputs, outputs
run: (input) =>
Effect.gen(function* () {
const draft = yield* step("draft-notes", () => draftWithClaude(input));
yield* step("create-draft-release", () => createDraftRelease(draft));
yield* step("notify-channel", () => pingApprovalChannel(draft));
const approval = yield* step.waitForEvent("release approval", {
type: "release-approval",
timeout: "72 hours",
payloadSchema: ApprovalPayload,
});
// The approval decision is a literal union — match it exhaustively so a
// new decision variant becomes a compile error, not 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: draft.nextTag,
}),
),
Match.when("approve", () =>
step("publish-release", () => publishRelease(draft)).pipe(
Effect.as({ published: true as const, tag: draft.nextTag }),
),
),
Match.exhaustive,
);
}),
});
The signaling endpoint is the Dispatcher’s /v1/admin/events/:wf_id, gated by Cloudflare Access (see 01-architecture § Dispatcher Worker). The approver clicks a link from the notification channel; CF Access SSOs them; the Worker re-verifies the Access JWT and calls env.RUNS_WORKFLOW.get(wfId).sendEvent({ type: "release-approval", payload: { decision, deciderEmail } }). The matching step.waitForEvent resumes the Workflow with the decoded payload.
Timeout produces a tagged error so OTel records an error span rather than swallowing the case:
export class ApprovalTimedOut extends Schema.TaggedError<ApprovalTimedOut>()(
"ApprovalTimedOut",
{ eventName: Schema.String, timeoutMs: Schema.Number },
) {}
Two-layer approval dedup is the receiver’s job, not the run’s: /v1/admin/events/:wf_id debounces (wf_id, decider_email) in IDEMPOTENCY_KV with a 1h window so two reviewers racing to approve produces deterministic ordering (first writer wins, second gets 409 Conflict).
Deferred scheduling with step.sleepUntil
Status: Planned (V3).
step.sleep/step.sleepUntilare not yet exposed on thestepexport at HEAD. The underlying CF Workflows primitive is available; the DSL wrapper lands with the first recipe that needs it (the staggered-fan-out example below is forward-looking).
A recurring schedule is a Cron Trigger (schedules above, 04-gha-integration § Schedule mode). A one-off future action — “re-check this PR in 24 hours,” “poll this deployment until it settles, then report” — is not a cadence and does not belong in wrangler.jsonc. It is a single durable sleep inside a run, and CF Workflows expresses it natively: a step can sleep for a relative duration or sleepUntil an absolute time. The Workflow hibernates for the interval — consuming no CPU, surviving Worker eviction — then resumes from the checkpoint.
The DSL exposes both:
declare const sleep: (name: string, duration: Duration | string) =>
Effect.Effect<void, never, RunContext>;
declare const sleepUntil: (name: string, wakeAt: Date | number) =>
Effect.Effect<void, never, RunContext>;
A scheduled sweep (recipes/ai-code-review § Scheduled sweep) uses sleepUntil to stagger its fan-out — spreading 200 child reviews over an hour so the GitHub API rate limit and the model provider are never hit in a burst:
import { Effect } from "effect";
import { step } from "@flare-dispatch/core";
import { sharded } from "@flare-dispatch/core/primitives";
// Spread N child dispatches evenly across `windowMs`, each on a durable sleep.
const staggered = (targets: readonly Target[], windowMs: number) =>
sharded({
count: targets.length,
concurrency: targets.length,
body: ({ index, total }) =>
Effect.gen(function* () {
const offset = Math.floor((windowMs / total) * (index - 1));
yield* step.sleepUntil(`stagger-${index}`, Date.now() + offset);
return yield* step(`dispatch-${index}`, () =>
spawnChildRun({ run: "pr-review", input: toInput(targets[index - 1]) }),
);
}),
});
sleep / sleepUntil do not count against the Workflow step-count quota (01-architecture § Platform limits), so a run may sleep freely. The wakeup time itself is non-deterministic input — derive it from io.now(), never a bare Date.now() outside a step, so checkpoint replay stays consistent (see § step Rules). The example above reads Date.now() only inside a step body, which is the checkpointed boundary.
Capability namespaces
All side-effectful operations live in one of these namespaces. Each is a Context.Tag-defined service backed by a Layer (real / dev / test).
sandbox
Container execution.
namespace sandbox {
// Acquire a container; auto-released at run end.
declare const acquire: (opts: { image?: string; memMB?: number; vCPU?: number }) =>
Effect.Effect<Container, ContainerLaunchFailed>;
// Convenience: clone a repo into a fresh container, return its path.
declare const git: {
clone: (opts: { repo: string; sha: string; container?: Container }) =>
Effect.Effect<string /* repoDir */, CheckoutFailed>;
};
// Execute a command in a container.
declare const exec: (opts: {
cwd?: string;
command: string | readonly string[];
env?: Record<string, string>;
timeoutSec?: number;
container?: Container;
}) => Effect.Effect<ExecResult, ExecFailed | ExecTimeout>;
// Detached mode for long-running processes (app boot during cdp-acceptance).
declare const runDetached: (opts: ExecOpts) =>
Effect.Effect<DetachedHandle, ContainerLaunchFailed>;
declare const waitForExit: (opts: { handle: DetachedHandle; pollEvery?: Duration }) =>
Effect.Effect<ExecResult, ExecTimeout>;
declare const waitForPort: (opts: { handle: DetachedHandle; port: number; timeoutSec?: number }) =>
Effect.Effect<void, PortNeverOpened>;
}
ExecResult carries exitCode, durationMs, logPath (R2 key for the captured stdout/stderr), and a stdout/stderr tail (last N KB inlined for convenience; full log streamed to R2).
A command that runs to completion always yields an ExecResult, whatever its exit code — a non-zero exitCode is a normal result (a failing test, a non-zero curl), surfaced to the run, not an Effect failure. sandbox.exec fails its Effect only when the command could not run as a process: ExecFailed when it could not be launched or was killed by a signal / a dying container, ExecTimeout when it exceeded timeoutSec. A run that wants a non-zero exit to abort must check result.exitCode itself.
browser
Browser Rendering access.
namespace browser {
// REST mode — short, stateless page interactions.
declare const newPage: (opts?: { viewport?: { w: number; h: number } }) =>
Effect.Effect<Page, BrowserUnavailable>;
// CDP mode — direct WebSocket to a managed Chromium.
declare const newCDPSession: (opts: { targetUrl: string }) =>
Effect.Effect<CDPSession, BrowserUnavailable>;
}
A Page wraps Puppeteer’s page object with Effect signatures (page.goto, page.click, page.evaluate all return Effects with tagged errors). A CDPSession exposes typed Network.*, Page.*, Runtime.* event streams as Effect Streams.
cache
R2-backed restore/save.
namespace cache {
declare const restoreOr: <A, E>(opts: {
key: string; // content-addressed; lockfile hash
paths: readonly string[]; // files/dirs to cache, relative to container cwd
onMiss: () => Effect.Effect<A, E, RunContext>;
container: Container;
}) => Effect.Effect<A, E | CacheError, RunContext>;
declare const save: (opts: {
key: string;
paths: readonly string[];
container: Container;
}) => Effect.Effect<void, CacheError, RunContext>;
}
restoreOr is the canonical pattern: try to restore; if missing, execute the onMiss effect (which presumably populates the paths) then save. Idempotent across re-executions.
artifact
R2-backed artifact upload. Returns the stable artifact URL the Dispatcher serves at GET /v1/artifacts/:executionId/:name (specs/01-architecture.md § Dispatcher Worker).
namespace artifact {
declare const upload: (opts: {
name: string;
path: string; // file or directory (dir tars to .tar.zst)
contentType?: string;
signedUrlTTL?: Duration | string; // accepted but ignored in V0-V2 — see below
container?: Container;
}) => Effect.Effect<string /* /v1/artifacts/<exec>/<name> */, ArtifactUploadFailed, RunContext>;
declare const list: (opts: { executionId: string }) =>
Effect.Effect<readonly ArtifactInfo[], never, RunContext>;
}
The returned URL is the stable path the Dispatcher streams the R2 object body through (not a true R2 presigned S3-API URL). The link resolves through the same Worker, so no R2 access-key-id / secret needs to enter the Worker Secret set. signedUrlTTL is accepted for forward compatibility but does not change the URL today; when real R2 S3 credentials enter the secret set (V3+), artifact.upload switches to issuing a presigned URL + the GET /v1/artifacts/... route 302-redirects to it. The path is the stable contract — callers don’t care which mechanism serves the bytes.
io
Effect-friendly access to non-deterministic primitives. Must be used instead of Date.now() / crypto.randomUUID() / process.env so step replay is deterministic.
type PriorExecution<O> = {
executionId: string; // ULID of the prior execution
sha: string; // its head SHA
output: O; // its recorded, schema-decoded output
finishedAt: number; // epoch ms
};
namespace io {
declare const now: Effect.Effect<number, never, RunContext>;
declare const uuid: Effect.Effect<string, never, RunContext>;
declare const env: (key: string) => Effect.Effect<string | undefined, never, RunContext>;
declare const sleep: (d: Duration) => Effect.Effect<void, never, RunContext>;
declare const log: (level: "debug" | "info" | "warn" | "error", msg: string, attrs?: Record<string, unknown>) =>
Effect.Effect<void, never, RunContext>;
// The most recent terminal execution in this run's semantic family — the
// instanceId prefix shared across re-runs of the same PR / branch. Powers
// re-reviews and incremental runs: read what last time concluded before
// deciding again. Option.none() on the first execution of a family.
declare const priorExecution: <O>(opts: {
family: string; // instanceId prefix, e.g. "pr-review:owner/name:42"
outputSchema: Schema.Schema<O, unknown>;
}) => Effect.Effect<Option.Option<PriorExecution<O>>, never, RunContext>;
// This execution's tokened log-viewer URL — the readable logs + produced
// artifacts surface. A run reads it to deep-link its own human-facing output
// (e.g. a PR comment) back to the full logs. Option.none() when the deploy
// has no public origin / log-link key.
declare const viewerUrl: Effect.Effect<Option.Option<string>, never, RunContext>;
}
io.priorExecution reads D1 execution metadata for the most recent terminal execution whose Workflow instanceId starts with family: — excluding the current one. The family is the semantic dedup key (see 04-gha-integration § Receiver dedup) minus its head-SHA component: pr-review:{repo}:{pr} rather than pr-review:{repo}:{pr}:{head_sha}. A run uses it to make its current decision relative to its last one — incremental review, “did this regress since the previous push,” resolving stale findings. The prior output is decoded against outputSchema; a decode mismatch (the prior execution ran an older run version with a different output shape) yields Option.none() rather than failing.
io.viewerUrl hands a run its own execution’s log-viewer URL — the tokened /logs/<id>?t=<token> surface that renders both the run’s logs and its produced artifacts (the artifact uploads). The dispatcher mints it (it owns the log-token secret) and threads it through the runtime, so a run can only read it, never forge one. pr-review is the canonical caller: it footers a 📋 View full logs & reviewed diff link onto every PR comment so a reviewer can jump from the comment to the exact diff that was reviewed (uploaded as the pr-review.diff artifact). Option.none() on a deploy with no public origin or no log-link key material — the run then renders its link-less historical form.
Status: live at HEAD.
priorExecutionis implemented inpackages/runtime-cf/src/io-live.ts— a real D1 query (SELECT … FROM executions WHERE id LIKE 'family:%' AND id != <current> AND status IN ('success','failure') ORDER BY completed_at DESC LIMIT 1), with the current execution excluded. It returnsOption.none()only when the D1 binding is absent (a stand-aloneIOLivefor tests / local dev), the priorsummary_jsonis NULL / unparseable, the decode againstoutputSchemamismatches (an older run version’s output shape), or a D1 read faults mid-lookup — all “best-effort tuning, never a hard run failure.”pr-reviewincremental mode is the canonical caller.viewerUrlis the same file’sOption.fromNullable(logsViewerBase)— the dispatcher passeslogsViewerBase(built fromPUBLIC_ORIGIN/request origin + the log-token secret) into the runtime; the GitHub check-run summary embeds the same URL.
config
Read-only access to dynamic configuration — model routing, provider enable/disable switches, feature flags — held in a CONFIG_KV namespace binding. Edits to that KV propagate to subsequent executions within seconds, with no wrangler deploy: the control plane is data, not code.
namespace config {
// Raw string value; undefined if the key is unset.
declare const get: (key: string) =>
Effect.Effect<string | undefined, never, RunContext>;
// Schema-decoded JSON value. A miss or a malformed value yields
// Option.none() (the decode failure is logged) — config is best-effort
// tuning, never a hard run failure.
declare const getJSON: <A>(key: string, schema: Schema.Schema<A, unknown>) =>
Effect.Effect<Option.Option<A>, never, RunContext>;
}
config is read-only from a run — runs never write it; operators edit CONFIG_KV directly (or through a small admin surface). Because every failure mode degrades gracefully — an unset key is undefined, a malformed JSON value is Option.none() — a run that reads config must always carry a sensible default. Config tunes behavior; it does not gate it. This is the seam for the kind of live model-routing / circuit-breaker control plane a multi-agent run wants (see recipes/ai-code-review) without coupling routing decisions to a redeploy.
github
Status: partially live. Exported from
@flare-dispatch/coreat HEAD (packages/core/src/services/github.tsis the source of truth for the exact shapes). Live in the deployed runtime:actionRuns(read),pullReviewandopenDraftPullRequest(the two deliberate writes — see below). Still deferred live (dying stubs): the installation-wide enumeration readsrepositories/openPullRequests— runs that need a repo list take it fromCONFIG_KVinstead (see thespec-drift-pr/ci-triage-prrecipes). The Dispatcher’s check-run write callback remains a runtime-onlychecksservice (packages/runtime-cf/src/checks-github.ts) that run bodies never call directly.
Access to the GitHub API, scoped to the installations of the FlareDispatch App. The Dispatcher already holds the App private key for the check-run write callback (04-gha-integration § Check-runs callback); github is the symmetric run-facing surface, backed by the same short-lived installation tokens. A run never sees a token — the capability Layer mints, caches, and scopes them (resolving the per-repo installation itself when a request carries no installationId, so Schedule-mode runs — which have no webhook-threaded installation — still work).
type RepoRef = {
repo: string; // "owner/name"
defaultBranch: string;
installationId: number;
archived: boolean;
pushedAt: number; // epoch ms — last push to any branch
};
type PullRequestRef = {
repo: string; // "owner/name"
number: number;
headSha: string;
baseSha: string;
title: string;
draft: boolean;
labels: readonly string[];
author: string;
installationId: number;
updatedAt: number; // epoch ms
};
namespace github {
// Every repo the App is installed on. The enumeration surface for
// Schedule-mode runs whose unit of work is a repo, not a PR — a nightly
// dependency audit, a scheduled full-suite run.
declare const repositories: (opts?: {
includeArchived?: boolean; // default false
pushedWithinDays?: number; // skip repos idle longer than this
}) => Effect.Effect<readonly RepoRef[], GitHubApiError, RunContext>;
// Open PRs across every repo the App is installed on. Paginates internally;
// backs off on secondary rate limits. The primary surface Schedule-mode
// sweeps enumerate against — a cron tick names no target, so the run must
// discover them (see 04-gha-integration § Schedule mode).
declare const openPullRequests: (opts?: {
updatedWithinHours?: number; // skip PRs idle longer than this
includeDrafts?: boolean; // default false
repos?: readonly string[]; // default: all installed repos
}) => Effect.Effect<readonly PullRequestRef[], GitHubApiError, RunContext>;
// Recent GitHub Actions workflow runs — the read `ci-triage-pr` scans for
// failures (`status: "completed", conclusion: "failure"`). LIVE.
declare const actionRuns: (opts?: {
repos?: readonly string[]; // pass explicitly (enumeration is deferred)
createdWithinHours?: number;
status?: string; // default "completed"
conclusion?: string; // e.g. "failure"
}) => Effect.Effect<readonly WorkflowRunRef[], GitHubApiError, RunContext>;
// WRITE exception 1 — a top-level PR review comment (event: COMMENT), so a
// run can leave an always-visible comment on success AND failure. LIVE.
declare const pullReview: (req: PullReviewRequest) =>
Effect.Effect<void, GitHubApiError, RunContext>;
// WRITE exception 2 — open (or update) a DRAFT pull request carrying file
// edits, committed via the Git Data API FROM THE WORKER (blob → tree →
// commit → ref → draft PR; no container `git push`). Idempotent on
// `headBranch`: a re-run fast-forwards the branch and reuses the open PR.
// The write the spec-drift-pr / ci-triage-pr recipes file their proposals
// through. LIVE. (`WorkflowRunRef` / request-and-result shapes: see
// packages/core/src/services/github.ts.)
declare const openDraftPullRequest: (req: OpenDraftPullRequest) =>
Effect.Effect<DraftPullRequestResult, GitHubApiError, RunContext>;
}
github is deliberately narrow, and read-mostly. The default posture stays: a run produces findings and an output, and the Dispatcher renders the check-run — the capability exists primarily so a run can discover what to act on. The two writes above are deliberate, bounded exceptions: pullReview because the read-only check-run summary can’t guarantee an always-visible comment, and openDraftPullRequest because a “detect → propose a fix” recipe’s entire product is a draft PR a human reviews (it never merges, never touches protected refs, and degrades to a logged no-op without App credentials). Anything beyond those stays out. GitHubApiError (§ Errors) is a transient-friendly tagged error: a 403 secondary-rate-limit is retryable on a Schedule, a 401 (revoked installation) is not.
cloudflare
Status: Live.
packages/core/src/services/cloudflare.ts(shapes),packages/runtime-cf/src/cloudflare-live.ts(live Layer).
The Cloudflare-side twin of the github read surface: a read-only window into the account so a Schedule-mode run can discover what to act on. Today it surfaces Pages deployments — the signal ci-triage-pr scans for failed deploys alongside failed Actions runs:
namespace cloudflare {
declare const deployments: (opts?: {
projects?: readonly string[]; // Pages project names (else all)
environment?: string; // "production" | "preview"
status?: string; // e.g. "failure"
createdWithinHours?: number;
}) => Effect.Effect<readonly DeploymentRef[], CloudflareApiError, RunContext>;
}
Backed by a scoped CLOUDFLARE_API_TOKEN Worker Secret (Pages:Read suffices) + the CLOUDFLARE_ACCOUNT_ID var; runs never see the token. Absent the secret, the capability degrades to empty (a triage sweep finds nothing CF-side) rather than failing the run — a read can afford that; contrast the dying Config stub. Deliberately read-only: mutating Cloudflare state stays a wrangler/CI concern, never a run’s.
oidc
The Dispatcher Worker is a first-class OIDC issuer. The oidc capability mints short-lived JWTs against a stable signing key whose public half is served at the Worker’s /.well-known/jwks.json (with /.well-known/openid-configuration for discovery). Any IdP that trusts the Worker’s issuer URL — AWS STS, GCP STS, HashiCorp Vault, an internal auth service — can federate against those tokens to issue scoped, short-lived credentials. No long-lived cloud-provider keys land in Worker Secrets.
type OidcToken = {
jwt: string; // compact-serialised JWS
expiresAt: number; // epoch ms
};
namespace oidc {
// Sign a fresh OIDC token. `audience` is the IdP-specific value the IdP's
// trust policy pins (e.g. "sts.amazonaws.com" for AWS, the resource URL
// for GCP STS). `subject` defaults to `<run-name>:<execution-id>` so an
// IAM trust policy can scope a role to a *specific run*.
declare const sign: (opts: {
audience: string;
subject?: string;
ttlSec?: number; // default 900, capped at 3600
claims?: Readonly<Record<string, string | number | boolean>>;
}) => Effect.Effect<OidcToken, OidcSigningFailed, RunContext>;
// The stable issuer URL the IdP's trust policy pins. Derived from the
// Worker's deployed origin (e.g. https://flare-dispatch.example.workers.dev)
// — surfaced here so a run can log it / surface it on the check-run.
declare const issuer: () => Effect.Effect<string, never, RunContext>;
}
The runtime Layer keeps the signing key opaque to the run — oidc.sign is the only path to a token, and a token is per-audience (never reused across IdPs). Token TTL caps at 60 minutes because every supported IdP STS endpoint refuses longer; runs that need a longer working window mint fresh tokens, they don’t widen the cap.
awsAssumeRole (primitive)
A two-line oidc.sign → sts:AssumeRoleWithWebIdentity exchange. The most-requested federation target is AWS — Bedrock for agentic runs, S3 for artifact mirroring, KMS for envelope-encrypted secrets — so the exchange ships as a primitive on top of the oidc capability (see § Primitives — awsAssumeRole).
The downstream consumer the capability was lifted from: an ai-code-review agent that calls bedrock:InvokeModel. Inside its run body:
const creds = yield* awsAssumeRole({
roleArn: "arn:aws:iam::123456789012:role/FlareDispatchBedrockReader",
sessionName: `flare-dispatch-${input.repo}`,
});
const bedrock = makeBedrockClient(creds); // user-supplied SDK adapter
const reply = yield* Effect.tryPromise(() =>
bedrock.invokeModel({ modelId: "anthropic.claude-opus-4-7-v1:0", body: ... }),
);
The pattern is the IAM-recommended workload identity federation: a stable issuer that the IAM trust policy pins, short-lived OIDC tokens scoped per-execution, per-call STS credentials with a 15-minute TTL. The trust policy snippet operators paste lives in 05-byoc § AWS federation trust policy.
OidcSigningFailed (§ Errors) wraps a key-load failure or a SubtleCrypto.sign reject — it is a deploy-time misconfiguration error, never transient, so runs that catch it should fail loudly rather than retry. StsAssumeRoleFailed (from the primitive) is the IdP-side failure — a 400 from a mistrusted issuer, a 403 from a misconfigured role — and similarly non-retryable.
modelGateway
Status: live at HEAD.
packages/core/src/services/model-gateway.ts(shapes),packages/runtime-cf/src/model-gateway-cf.ts(live Layer).
A provider-agnostic “ask a model one turn” capability — complete sends a system + user message (optionally with a tool the model may call) and returns the model’s tool calls and/or free text. It is deliberately not “Workers AI” or “/chat/completions”: those are backends. The model id carries a backend prefix the gateway dispatches on, so an operator switches backend from CONFIG_KV without a wrangler deploy (see § config): @cf/... → Workers AI catalog (binding-as-auth, no key travels with the request); anthropic/<model> → Anthropic Messages API via AI Gateway (BYOK); bedrock/<model> → AWS Bedrock InvokeModel via AI Gateway (the per-execution aws STS creds from awsAssumeRole SigV4-sign the call — no deploy-time AWS key).
namespace modelGateway {
declare const complete: (req: {
model: string; // backend-prefixed model id (see above)
system: string;
user: string;
tools?: readonly ModelTool[]; // present → forced tool choice where supported
maxTokens?: number;
temperature?: number;
aws?: { // bedrock/* only — per-execution STS creds
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
region: string;
};
}) => Effect.Effect<ModelCompletionResult, ModelGatewayError, RunContext>;
}
ModelCompletionResult carries toolCalls (empty when the model answered with text), text (empty when it answered with a tool call), and optional inputTokens / outputTokens usage telemetry (Bedrock / Anthropic populate them; the Workers AI catalog leaves them undefined — log them when present, never gate on them). A model that simply declined to call a tool is not a failure — it surfaces as an empty toolCalls in a successful result. ModelGatewayError (defined alongside the service in model-gateway.ts, not in the central RunError union) is the backend-side failure (auth-failed / rate-limited / bad-response / timeout / unknown), provider-agnostic so the calling engine maps it to its own error. The capability is the seam the agentic pr-review run calls (see recipes/ai-code-review); a fake (@flare-dispatch/core/testing’s makeModelGatewayFake) returns canned outputs so a model-calling run is unit-testable without the network.
email
Status: live at HEAD, opt-in.
packages/core/src/services/email.ts(shapes),packages/runtime-cf/src/email-cf.ts(makeEmailCloudflareLive, backed by Cloudflare Email Routing’ssend_emailbinding).
A reporting capability, in the same family as checks: a run’s success or failure must never hinge on whether a notification went out. So send is total — it has no error channel. Per-recipient delivery outcomes are returned as data; a deploy with no email backend degrades to skipped: true (a logged no-op) rather than failing the run.
namespace email {
declare const send: (req: {
to: readonly string[]; // Email Routing destinations (see below)
subject: string;
html: string; // primary rendering
text?: string; // plain-text alternative
}) => Effect.Effect<EmailSendResult, never, RunContext>;
}
EmailSendResult partitions the requested recipients into accepted + rejected (a partial failure rejects only the offending addresses, never the whole batch), carries the provider messageId of the first accepted send when available, and sets skipped: true when no backend is configured. The capability is opt-in: it is a logged no-op until send_email is configured, and the Cloudflare backend requires each recipient to be a verified Email Routing destination address (the provider rejects others) plus an EMAIL_ALLOWED_RECIPIENTS allowlist. The interface is provider-agnostic — a Resend / MailChannels / SES Layer can back the same Tag with no run-level change. The run-authored failure-summary emails on red checks (04-gha-integration § Notifications) ride this surface.
childRuns
Status: live at HEAD.
packages/core/src/services/child-runs.ts(shapes + thespawnChildRunaccessor),packages/runtime-cf/src/child-runs-cf.ts(live Layer). ThewaitForChildrenjoin is a primitive built on it.
The child-Workflow fan-out lever — “enumerate, then fan out” (01-architecture § Scheduling). spawnChildRun instantiates an independent RunWorkflow instance — a child execution in its own durable Workflow, with its own step budget, its own container, and (for browser runs) its own Browser Rendering session. This is distinct from the sharded primitive, which fans out inside one instance and shares the parent’s CPU/step budget: use sharded for a handful of shards on one container; use childRuns (via the fanOut primitive) when shard count, per-shard duration, or per-shard browser sessions would strain a single instance. matrix-fanout is the worked example.
type ChildRunHandle = {
executionId: string; // == instanceId; build a details URL from it
instanceId: string; // == executionId; the dedup key
created: boolean; // false when an instance with this id already existed
};
namespace childRuns {
// Spawn a child RunWorkflow instance.
declare const spawn: (opts: {
run: string; // child run name — registered on the same deploy
input: unknown; // validated against the CHILD's inputs Schema (a mismatch fails the child)
instanceId?: string; // omitted → deterministic id from parent exec + run + input hash (replay-safe)
}) => Effect.Effect<ChildRunHandle, ChildSpawnFailed, RunContext>;
// Read each child's lifecycle status (running | success | failure | cancelled |
// missing) + its summary_json when terminal. Total — a transient D1 read fault
// degrades to a non-terminal status; waitForChildren's timeout is the backstop.
declare const poll: (opts: { ids: readonly string[] }) =>
Effect.Effect<readonly ChildStatusRecord[], never, RunContext>;
}
A run author imports the spawnChildRun accessor (spawnChildRun({ run, input })), never the Tag — it’s the call the playwright-e2e and staggered-sweep sketches above already use. Spawns are idempotent on replay: an omitted instanceId is derived deterministically from the parent execution id + run name + a hash of the input, so a parent Workflow replay recreates the same id — and CF Workflows treats a duplicate create({id}) as a no-op (created: false), exactly the idempotency a fan-out parent wants. ChildSpawnFailed (§ Errors) is the spawn-side failure. The join half — block until every spawned child settles, then roll up their decoded outputs — is waitForChildren.
Primitives
Capabilities are atomic. Recipes are not — every recipe needs to check out a repo, most need to fan out across shards, several need to boot the app under test or install dependencies with a cache. Left to raw capabilities, every recipe re-derives the same five-line acquire → clone → install dance, the same Effect.forEach(Array.from({ length: n }, …), …, { concurrency }) fan-out, the same curl-and-classify probe loop.
Primitives are those recurring shapes, lifted out of the recipes and shipped by the DSL. A primitive is a plain Effect-TS function: it composes capabilities (and other primitives), threads the same RunContext, fails with the same tagged errors, and swaps Layers for tests exactly like a capability call. It adds no new runtime — only a smaller, higher-level surface to write recipes against.
Primitives live in @flare-dispatch/core/primitives — their source is packages/core/src/primitives/, the ./primitives sub-path of the core package (packages/core/). A recipe imports the frame and capabilities from @flare-dispatch/core and the compositions from @flare-dispatch/core/primitives — the two import paths make the layer boundary visible at the top of every recipe file.
import { defineRun, step, sandbox, artifact } from "@flare-dispatch/core";
import { workspace, sharded } from "@flare-dispatch/core/primitives";
workspace
Acquire a container and clone a repo into it — the opening move of nearly every recipe — optionally followed by a cached dependency install. Returns the container handle and the checkout directory together so the rest of the run threads one value, not two.
type Workspace = { container: Container; dir: string };
declare const workspace: (opts: {
repo: string;
sha: string;
image?: string; // container image override
install?: boolean; // run installCached after clone
}) => Effect.Effect<
Workspace,
ContainerLaunchFailed | CheckoutFailed | CacheError | ExecFailed,
RunContext
>;
// packages/core/src/primitives/workspace.ts (sketch)
export const workspace = (opts) =>
Effect.gen(function* () {
const container = yield* sandbox.acquire({ image: opts.image });
const dir = yield* sandbox.git.clone({
repo: opts.repo, sha: opts.sha, container,
});
if (opts.install) yield* installCached({ container, dir });
return { container, dir };
});
installCached
The cache-pnpm / npm / cargo / uv building block from 02-runs: detect the lockfile, derive a content-addressed cache key, restore the dependency tree from R2 or run the install on a miss, then save. Idempotent across step replay. workspace({ install: true }) is the common caller; a recipe that needs to install at a non-standard point calls it directly.
declare const installCached: (opts: {
container: Container;
dir: string;
tool?: "pnpm" | "npm" | "cargo" | "uv"; // default: auto-detect from lockfile
}) => Effect.Effect<void, CacheError | ExecFailed, RunContext>;
loadSecrets
Pull named secrets from config (KV) into a Record<string, string> ready to hand sandbox.exec({ env }) or bootApp. The “inject credentials into the container” preamble of any run whose command needs a Clerk key, a Cloudflare API token, an LLM provider key, and so on — rather than threading secrets through GHA repo secrets and the dispatch body, the operator stores them once in the FlareDispatch config store (KV) and the run names the keys it needs.
declare const loadSecrets: (
keys: readonly string[],
opts?: {
prefix?: string; // default: ""; e.g. "secret/" to namespace
required?: boolean; // default: false; true fails with SecretsMissing
},
) => Effect.Effect<Record<string, string>, SecretsMissing, RunContext>;
By default a missing key (unset, empty, or an errored config read) is omitted from the result and logged at warn — graceful degradation suited to optional values. Credentials are usually not optional: required: true fails with a SecretsMissing tagged error listing the absent keys, so a misconfigured deploy fails fast and legibly instead of booting a container that breaks mid-run.
Important: call loadSecrets inline in the run body, not wrapped in step(...). CF Workflows persist every step’s return value to durable storage for replay; a checkpointed Record<string,string> of plaintext credentials would sit in Workflow state at rest. The config read is cheap and idempotent on replay, so the inline call recomputes deterministically without leaving credentials in checkpointed state. The cdp-acceptance run (02-runs § cdp-acceptance) is the canonical caller.
sharded
The fan-out shape: run count parallel copies of a body, each handed its { index, total } (1-based), bounded by concurrency. Collapses the hand-rolled Effect.forEach(Array.from({ length: n }, (_, i) => i + 1), …, { concurrency }) that every matrix-style recipe otherwise repeats, and gives the index plumbing one canonical shape.
declare const sharded: <A, E>(opts: {
count: number;
concurrency?: number; // default: count
body: (shard: { index: number; total: number }) =>
Effect.Effect<A, E, RunContext>;
}) => Effect.Effect<readonly A[], E, RunContext>;
Fan-out across a list of heterogeneous items (scanners, review agents) stays plain Effect.forEach — sharded is specifically the count-and-index case. The DSL does not wrap what Effect already expresses cleanly.
fanOut
The cross-instance counterpart of sharded, built on the childRuns capability: spawn one independent child RunWorkflow per item in a list — each its own durable Workflow with its own step budget, container, and Browser Rendering session — so the parallelism ceiling is the account-level limit, not the parent instance’s per-instance step quota or per-exec container-kill window. Call it inside a step("fanout", () => fanOut(...)) so the spawn set is one checkpoint; the deterministic child instance ids make the underlying create idempotent on replay regardless. It is intentionally fire-and-spawn: it returns the spawn handles, not the children’s outputs — joining is waitForChildren’s job.
declare const fanOut: <T>(opts: {
run: string; // child run name (registered on the same deploy)
items: readonly T[]; // one child per item
toInput: (item: T, shard: Shard) => unknown; // item → child input
toInstanceId?: (item: T, shard: Shard) => string; // omit → deterministic id (replay-safe)
concurrency?: number; // default: all at once (spawn is a cheap create)
}) => Effect.Effect<readonly ChildRunHandle[], ChildSpawnFailed, RunContext>;
Use sharded for a handful of shards on one container; reach for fanOut when shard count, per-shard duration, or per-shard browser sessions would strain a single instance. matrix-fanout is the worked caller.
waitForChildren
The join half of a fanOut: poll the childRuns capability for each spawned child’s status until every one settles (success / failure / cancelled), then return their records — including each terminal child’s summaryJson, so a parent can decode and roll up its children’s outputs (merge N shard reports into one, count pass/fail). Each poll is a single cheap D1 read separated by io.sleep, never a long-held connection — and CF Workflows bounds only CPU, not wall-clock per step, so a parent can wait out a 25-minute matrix from inside one step("await-children", ...). The loop is bounded by maxAttempts = ceil(timeout / pollEvery) (a count, not a wall-clock read), so io.now drift across a checkpoint resume cannot change how many polls run — it stays replay-deterministic.
declare const waitForChildren: (opts: {
ids: readonly string[]; // child execution ids — typically fanOut handle ids
pollEvery?: Duration | string; // default "5 seconds"
timeout?: Duration | string; // overall ceiling before ChildWaitTimeout; default "30 minutes"
}) => Effect.Effect<readonly ChildStatusRecord[], ChildWaitTimeout, RunContext>;
A fan-out over zero ids returns [] immediately. ChildWaitTimeout (§ Errors) carries the still-pending ids and the elapsed waitedMs when the ceiling lapses with children unsettled.
bootApp
Start a long-running process in a detached container and block until it is accepting connections — sandbox.runDetached followed by sandbox.waitForPort. The “boot the app under test” preamble of every acceptance-style recipe, as one call.
declare const bootApp: (opts: {
container: Container;
dir: string;
command: string | readonly string[];
port: number;
timeoutSec?: number; // wait-for-port ceiling, default 120
}) => Effect.Effect<DetachedHandle, ContainerLaunchFailed | PortNeverOpened, RunContext>;
probeHttp
Hit a set of paths under a base URL and classify each as healthy or not — a non-2xx/3xx status or a request that never completed counts as failed. Codifies the curl-and-classify loop a smoke test would otherwise spell out, including the -f-vs-no--f curl exit-code subtlety (see § sandbox).
type ProbeResult = { path: string; status: number; ok: boolean };
declare const probeHttp: (opts: {
baseURL: string;
paths: readonly string[];
container?: Container;
okStatus?: (code: number) => boolean; // default: 200 ≤ code < 400
}) => Effect.Effect<
{ checked: number; failed: number; results: readonly ProbeResult[] },
ExecFailed,
RunContext
>;
awsAssumeRole
Exchange a Dispatcher-issued OIDC token for short-lived AWS STS credentials via sts:AssumeRoleWithWebIdentity — the workload-identity-federation pattern AWS recommends in place of long-lived access keys in Worker Secrets. Rides on the oidc capability (§ oidc).
type AwsCredentials = {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
expiresAt: number; // epoch ms
};
declare const awsAssumeRole: (opts: {
roleArn: string;
sessionName?: string; // default: `<run>-<executionId>`
durationSec?: number; // default 900, AWS caps at 3600
region?: string; // default us-east-1
audience?: string; // default "sts.amazonaws.com"
}) => Effect.Effect<
AwsCredentials,
OidcSigningFailed | StsAssumeRoleFailed,
RunContext
>;
A run hands the credentials to whatever AWS SDK it uses; the DSL ships no Bedrock / S3 / KMS adapters. Each call mints a fresh OIDC token and a fresh STS exchange — credentials are never cached across awsAssumeRole calls within a run, because the scope (the session name) and the working window are part of the per-call contract. A run that needs many AWS calls in tight succession passes the same credentials to its SDK and lets the SDK handle the per-request signing.
Federating against a different cloud provider (GCP STS, Azure AD workload identity) follows the same shape; if a second target materialises in a shipped recipe the DSL adds gcpAccessToken / azureAccessToken primitives on the same oidc foundation.
isoDate / parseList
Pure, capability-free trivia the Schedule-mode runs share (packages/core/src/primitives/scheduling.ts): isoDate(ms) → the UTC YYYY-MM-DD used for cron-window dedup keys and dated branch/file names; parseList(raw) splits a comma/whitespace-separated CONFIG_KV value (repo lists, project lists). They live here — not in a sibling runs/ helper — so a recipe that is a verbatim copy of its deployed run still resolves the import.
Adding a primitive
A new primitive earns its place when a shape recurs across two or more recipes and is awkward enough that copy-paste drifts. It must: compose only capabilities and existing primitives (no new Layer, no new Context.Tag); fail with existing tagged errors from § Errors; and stay a pure Effect so it inherits Layer-swapping and the unit-test story unchanged. A one-off shape used by a single recipe stays inline in that recipe — premature primitives are just indirection.
Distribution and supply chain
The three tiers ship two different ways, and the split is deliberate.
@flare-dispatch/core is a library — pinned, not copied. Capabilities, defineRun, step, the runEffect shim, the Layer wiring, and the primitives are one npm package. It is framework code with a real runtime: a bug in step’s checkpoint logic or in installCached’s cache key is a correctness and a security failure, and you want it fixed in one pnpm update, not forked across a hundred repos. You trust @flare-dispatch/core the way you trust effect or wrangler — it is the irreducible trusted base.
Recipes are copy-paste — owned, not depended on. A recipe is your CI policy: which risk tier runs which agents, which scanners fire, where an approval gate sits. Divergence between repos is the point. The recipes/ directory is a starter library you copy in, edit, and review in your own PRs; recipes are never an npm dependency.
Why primitives stay in the library
Primitives are the one layer where shipping a library versus letting each project generate its own — the shadcn model — is a real question. For FlareDispatch the answer is the library, for three reasons.
A primitive is a sub-path of a package you already fully trust. @flare-dispatch/core/primitives is packages/core/src/primitives/, not a separate package. No recipe can be written without @flare-dispatch/core — defineRun and step live there, and step runs in the high-privilege Workflow alongside the D1 / R2 / KV bindings. Whether workspace ships inside that package or is copied into your repo, the trusted set is identical. Copying primitives out removes nothing from the attack surface — it only makes workspace un-patchable while step, which is strictly more dangerous, stays live.
Pinning already delivers what copy-paste promises. The control that matters is depending on an exact version of @flare-dispatch/core (no caret range), committing the lockfile, and installing with --frozen-lockfile. A pinned @flare-dispatch/[email protected] freezes workspace exactly as hard as a file checked into your repo would — and freezes step too, which copy-paste cannot. Copy-paste primitives is a weaker form of that control applied to a smaller slice of the code.
A primitive has a correct implementation, not a preferred one. The shadcn model suits code where divergence is taste — a <Button> should look the way you want. A primitive is logic with one right answer: installCached’s content-addressed cache key is a security property, and fifty hand-tweaked copies are fifty subtly-broken cache-integrity guarantees. The DSL ships one tested implementation on purpose.
The library path has one genuine cost: an upgrade lands as a 1.4.2 → 1.4.3 lockfile diff, not as reviewable source — where copy-paste would surface the change as a code diff in your PR. If your threat model requires reviewing the source of every dependency change, read the release diff when you bump (the primitives are a handful of small files — packages/core/src/primitives/), or eject.
Ejecting
A high-assurance deployment that must own the primitive source outright can eject: the V4 init CLI (pm/plan) exposes an eject primitives command that copies the primitive sources into the repo and rewrites recipe imports from @flare-dispatch/core/primitives to the local path. Ejecting is opt-in and one-way — it trades patchability for an in-repo audit trail. It is deliberately not the default: a default of ejected would make the primitives un-patchable for every deployment in order to serve the few that need it.
The surface that actually matters
None of the hardening below is the library-versus-copy-paste question, and all of it outranks it.
- Provenance.
@flare-dispatch/coreis published with npm provenance attestations from CI, so a consumer can verify the package was built from the tagged source. Consumers pin exact versions and install--frozen-lockfile. - Transitive dependencies. A recipe author audits five small primitives in minutes; nobody audits four hundred transitive packages.
@flare-dispatch/core’s own dependency tree is the real surface — keep it minimal, and treat each new transitive dependency as a reviewable change. - Cache poisoning — the FlareDispatch-specific vector.
installCachedwrites a dependency tree to R2 that is restored into every subsequent container for that repo. Content-addressed keys (lockfile hash + image digest) make cross-environment poisoning impossible only if writes to the cache prefix are themselves trusted: treat a restored tree as verified by its content, not by its key path — record the archive’s own digest at save and check it on restore. See 01-architecture § R2 layout. - The Dispatcher holds the crown jewels. The GitHub App private key and the HMAC secret are Worker Secrets on the Dispatcher; a compromise there reaches every repository the App is installed on. Its dependency hygiene and secret handling outrank every concern above. See 05-byoc.
Errors
All run errors are Schema.TaggedErrors, defined in @flare-dispatch/core/errors:
export class CheckoutFailed extends Schema.TaggedError<CheckoutFailed>()(
"CheckoutFailed",
{ repo: Schema.String, sha: Schema.String, cause: Schema.Unknown },
) {}
export class ExecFailed extends Schema.TaggedError<ExecFailed>()(
"ExecFailed",
{ exitCode: Schema.Number, stderrTail: Schema.String },
) {}
export class ExecTimeout extends Schema.TaggedError<ExecTimeout>()(
"ExecTimeout",
{ timeoutSec: Schema.Number, command: Schema.String },
) {}
export class ContainerLaunchFailed extends Schema.TaggedError<ContainerLaunchFailed>()(
"ContainerLaunchFailed",
{ image: Schema.String, cause: Schema.Unknown },
) {}
export class PortNeverOpened extends Schema.TaggedError<PortNeverOpened>()(
"PortNeverOpened",
{ port: Schema.Number, timeoutSec: Schema.Number },
) {}
export class BrowserUnavailable extends Schema.TaggedError<BrowserUnavailable>()(
"BrowserUnavailable",
{ reason: Schema.Literal("quota", "transient", "session-cap"), retryAfterMs: Schema.optional(Schema.Number) },
) {}
export class CacheError extends Schema.TaggedError<CacheError>()(
"CacheError",
{ phase: Schema.Literal("restore", "save"), key: Schema.String, cause: Schema.Unknown },
) {}
export class ArtifactUploadFailed extends Schema.TaggedError<ArtifactUploadFailed>()(
"ArtifactUploadFailed",
{ name: Schema.String, cause: Schema.Unknown },
) {}
export class StepFailed extends Schema.TaggedError<StepFailed>()(
"StepFailed",
{ step: Schema.String, cause: Schema.Unknown },
) {}
export class SecretsMissing extends Schema.TaggedError<SecretsMissing>()(
"SecretsMissing",
{ keys: Schema.Array(Schema.String), prefix: Schema.String },
) {}
export class ApprovalTimedOut extends Schema.TaggedError<ApprovalTimedOut>()(
"ApprovalTimedOut",
{ eventName: Schema.String, timeoutMs: Schema.Number },
) {}
export class EventPayloadInvalid extends Schema.TaggedError<EventPayloadInvalid>()(
"EventPayloadInvalid",
{ eventName: Schema.String, reason: Schema.String },
) {}
export class GitHubApiError extends Schema.TaggedError<GitHubApiError>()(
"GitHubApiError",
{
status: Schema.Number, // HTTP status from the GitHub API
reason: Schema.Literal("rate-limited", "unauthorized", "transient", "other"),
retryAfterMs: Schema.optional(Schema.Number),
},
) {}
export class OidcSigningFailed extends Schema.TaggedError<OidcSigningFailed>()(
"OidcSigningFailed",
{
// "key-load" — signing key absent or malformed (Worker secret unset /
// non-JWK / wrong alg);
// "subtle-sign" — WebCrypto SubtleCrypto.sign rejected.
reason: Schema.Literal("key-load", "subtle-sign"),
cause: Schema.Unknown,
},
) {}
export class StsAssumeRoleFailed extends Schema.TaggedError<StsAssumeRoleFailed>()(
"StsAssumeRoleFailed",
{
provider: Schema.Literal("aws", "gcp", "azure", "other"),
status: Schema.Number, // HTTP status from the STS endpoint
// "mistrusted-issuer" — the IdP does not trust the Dispatcher's JWKS URL;
// "role-mismatch" — the role's trust policy rejects this subject;
// "audience-mismatch" — the IdP audience the run signed for is wrong;
// "other" — any other 4xx/5xx from the STS endpoint.
reason: Schema.Literal("mistrusted-issuer", "role-mismatch", "audience-mismatch", "other"),
},
) {}
export class ChildSpawnFailed extends Schema.TaggedError<ChildSpawnFailed>()(
"ChildSpawnFailed",
{ run: Schema.String, instanceId: Schema.String, cause: Schema.Unknown },
) {}
export class ChildWaitTimeout extends Schema.TaggedError<ChildWaitTimeout>()(
"ChildWaitTimeout",
{ pending: Schema.Array(Schema.String), waitedMs: Schema.Number },
) {}
export type RunError =
| CheckoutFailed | ExecFailed | ExecTimeout
| ContainerLaunchFailed | PortNeverOpened | BrowserUnavailable
| CacheError | ArtifactUploadFailed | StepFailed | SecretsMissing
| ApprovalTimedOut | EventPayloadInvalid | GitHubApiError
| OidcSigningFailed | StsAssumeRoleFailed
| ChildSpawnFailed | ChildWaitTimeout;
Runs recover with Effect.catchTag / Effect.catchTags. Anything not caught fails the execution with the full Cause attached to the check-run summary.
Retry patterns
Transient infra errors (BrowserUnavailable with reason: "transient", ExecFailed from a container that died mid-command, launch flakes) should retry. Deterministic outcomes should not: a non-zero ExecResult.exitCode is the user’s command result (a failing test — surfaced to the check-run, never retried), and a ContainerLaunchFailed from a bad image is a config error.
import { Effect, Schedule } from "effect";
const launchPlaywright = browser.newCDPSession({ targetUrl }).pipe(
Effect.retry(
Schedule.exponential("500 millis").pipe(
Schedule.intersect(Schedule.recurs(4)),
Schedule.whileInput((e: BrowserUnavailable) => e.reason === "transient"),
),
),
);
Schedule.whileInput accesses _tag indirectly through the predicate — this is the documented Schedule API, not a branching escape hatch.
Pattern matching
Run authors converting tagged outcomes to summaries use Match.tag + Match.exhaustive:
import { Match } from "effect";
const summarize = (e: RunError): string =>
Match.value(e).pipe(
Match.tag("CheckoutFailed", ({ repo, sha }) => `Could not check out ${repo}@${sha}`),
Match.tag("ExecFailed", ({ exitCode, stderrTail }) => `Command exited ${exitCode}\n${stderrTail}`),
Match.tag("ExecTimeout", ({ timeoutSec, command }) => `Timed out after ${timeoutSec}s: ${command}`),
Match.tag("ContainerLaunchFailed", ({ image }) => `Could not launch container ${image}`),
Match.tag("BrowserUnavailable", ({ reason }) => `Browser unavailable (${reason})`),
Match.tag("CacheError", ({ phase, key }) => `Cache ${phase} failed for key ${key}`),
Match.tag("ArtifactUploadFailed", ({ name }) => `Artifact upload failed: ${name}`),
Match.tag("StepFailed", ({ step }) => `Step "${step}" failed`),
Match.tag("SecretsMissing", ({ keys, prefix }) => `Missing secret keys (${prefix}${keys.join(", ")})`),
Match.tag("ApprovalTimedOut", ({ eventName }) => `Approval "${eventName}" timed out`),
Match.tag("GitHubApiError", ({ status, reason }) => `GitHub API ${status} (${reason})`),
Match.tag("EventPayloadInvalid", ({ eventName, reason }) => `Event "${eventName}" payload invalid: ${reason}`),
Match.exhaustive,
);
Match.exhaustive ensures adding a new RunError variant is a compile error until every summary path handles it.
Layers — how the run binds to runtimes
// Production: wires sandbox → Cloudflare Containers, browser → Browser Rendering, etc.
export const CFRuntimeLive = Layer.mergeAll(
SandboxCloudflareLive,
BrowserRenderingLive,
R2CacheLive,
R2ArtifactLive,
D1IOLive,
);
// Local dev: wires sandbox → local Docker via wrangler dev miniflare, browser → local Chromium.
export const CFRuntimeDev = Layer.mergeAll(
SandboxLocalDockerLive,
BrowserPuppeteerLocalLive,
R2LocalLive,
R2ArtifactLocalLive,
D1LocalLive,
);
// Test: in-memory fakes.
export const CFRuntimeTest = Layer.mergeAll(
SandboxFake,
BrowserFake,
CacheFake,
ArtifactFake,
IOFake,
);
The same run executes against any of these:
import { Effect } from "effect";
import { CFRuntimeLive } from "@flare-dispatch/runtime-cf";
import { offloadTest } from "./runs/offload-test";
const program = offloadTest.run({
repo: "owner/name",
sha: "abc123",
command: "pnpm test",
});
Effect.runPromise(program.pipe(Effect.provide(CFRuntimeLive)));
Unit-testing runs
import { it, expect, vi } from "@effect/vitest";
import { Effect, Layer } from "effect";
import { offloadTest } from "./offload-test";
import { CFRuntimeTest, sandboxFakeProgram } from "@flare-dispatch/runtime-test";
it.effect("offload-test reports exit code from sandbox exec", () =>
Effect.gen(function* () {
const fakeSandbox = sandboxFakeProgram({
"git clone": { exitCode: 0 },
"pnpm test": { exitCode: 1, stderrTail: "1 failing" },
});
const result = yield* offloadTest.run({
repo: "owner/name",
sha: "abc",
command: "pnpm test",
});
expect(result.exitCode).toBe(1);
}).pipe(Effect.provide(Layer.merge(CFRuntimeTest, fakeSandbox))),
);
Run tests execute in vitest without touching CF, Docker, or the network. Catches the bulk of run bugs before any container ever boots.
Worked example — playwright-e2e (abbreviated)
export const playwrightE2E = defineRun({
name: "playwright-e2e",
version: "1.0.0",
inputs: PlaywrightInput,
outputs: PlaywrightOutput,
limits: { maxDurationSec: 2400, maxConcurrency: 8, requiresBrowser: true },
run: (input) =>
Effect.gen(function* () {
const shardPlan = yield* step("plan", () =>
Effect.succeed(
Array.from({ length: input.shards }, (_, i) => ({ index: i + 1, total: input.shards })),
),
);
const shardResults = yield* step("fanout", () =>
Effect.forEach(
shardPlan,
(shard) =>
spawnChildRun({
run: "playwright-e2e-shard",
input: { ...input, shard },
}),
{ concurrency: input.shards },
),
);
const reportUri = yield* step("merge-reports", () =>
mergeAndUploadReports(shardResults),
);
return summarizeShards(shardResults, reportUri);
}).pipe(
Effect.catchTag("BrowserUnavailable", (e) =>
// Match on the failure reason: a hard quota wall becomes its own tagged
// error; transient / session-cap failures propagate unchanged for the
// retry Schedule to handle.
Match.value(e.reason).pipe(
Match.when("quota", () =>
Effect.fail(new BrowserQuotaExhausted({ retryAfterMs: e.retryAfterMs ?? 60_000 })),
),
Match.orElse(() => Effect.fail(e)),
),
),
),
});
The shard child is a separate run (playwright-e2e-shard), kept thin: clone, install (cached), playwright test --shard i/N, upload partial report.