Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/dispatcher/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface DashboardData {
readonly nowMs: number;
/** `owner/repo` slug for the source link. */
readonly repoSlug: string;
/** The spend guard's month-to-date line (`GET /api/spend`); absent when unreadable. */
readonly spendLine?: string;
}

/** Server-side filters, parsed from the `run`/`repo`/`status` query params. */
Expand Down Expand Up @@ -196,6 +198,7 @@ const STYLE = `
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; margin-bottom: .25rem; }
h1 { font-size: 1.4rem; margin: 0; letter-spacing: -0.01em; }
.tagline { color: #9aa3b2; margin: 0 0 1.75rem; }
.spend { color: #9aa3b2; margin: -1.25rem 0 1.75rem; font-size: 0.9rem; }
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .08em; color: #9aa3b2; margin: 2rem 0 .6rem; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-weight: 600; color: #9aa3b2; padding: .4rem .5rem; border-bottom: 1px solid #232838; }
Expand Down Expand Up @@ -375,7 +378,7 @@ export const renderDashboard = (data: DashboardData): string => {
<h1>FlareDispatch</h1>
</header>
<p class="tagline">Dispatch CI/CD runs to Cloudflare — inspect logs, executions, and product demos.</p>

${data.spendLine !== undefined ? `<p class="spend">${esc(data.spendLine)}</p>\n` : ""}
<main>
<h2>Latest executions</h2>
${renderFilters(data.filters)}
Expand Down
38 changes: 38 additions & 0 deletions apps/dispatcher/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,44 @@ export interface Env {
*/
readonly ADMISSION_CAP_AGENT?: string;

/**
* The browser pool's admission slots — a var, carried as a string. The
* browser pool admits at most the smaller of this and `ADMISSION_CAP`; keep
* it ≤ `RunSandboxBrowser`'s `max_instances`. Absent / unparsable → 16
* (`BROWSER_ADMISSION_CAP_DEFAULT`).
*/
readonly ADMISSION_CAP_BROWSER?: string;

/**
* The monthly spend budget in USD (specs/adr/0006-spend-guard.md) — a var.
* At or over it, admission skips every run with a neutral check. Unset or
* unparsable → 50; `unlimited` disables it. CONFIG_KV
* `spend.budgetMonthlyUsd` overrides it without a deploy. The default lives
* in code (core spend.ts), because an operator overlay replaces the
* committed wrangler.jsonc.
*/
readonly SPEND_BUDGET_MONTHLY_USD?: string;

/**
* The burn ceiling in USD per hour — a var. The summed hourly rate of every
* admitted execution, across all pools, stays at or under it. Unset or
* unparsable → 1.50; `unlimited` disables it. CONFIG_KV
* `spend.burnMaxUsdPerHour` overrides it.
*/
readonly SPEND_BURN_MAX_USD_PER_HOUR?: string;

/**
* Multiplier on metered container + model spend for Workflow steps and
* Durable Objects — a var. Unset or outside [1, 10] → 1.10.
*/
readonly SPEND_OVERHEAD_FACTOR?: string;

/**
* Fraction of the monthly budget whose crossing sends the month's one
* warning on the notice path — a var. Unset or outside (0, 1] → 0.8.
*/
readonly SPEND_WARN_RATIO?: string;

/**
* Cloudflare account id (the 32-hex id in the dashboard URL) — a var, not a
* secret. When set, `RunWorkflow` builds a `details_url` on the GitHub
Expand Down
17 changes: 16 additions & 1 deletion apps/dispatcher/src/http-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { handleProductDemo } from "./routes/product-demos";
import { handleReplay } from "./routes/replay";
import { handleGithubWebhook } from "./routes/webhook";
import { handleSignalsWebhook } from "./routes/signals-webhook";
import { handleSpend, readSpendNow, spendLine, spendView } from "./routes/spend";

/** `owner/repo` slug for the dashboard source link. */
const REPO_SLUG = "fractalbox/flare-dispatch";
Expand Down Expand Up @@ -368,13 +369,16 @@ const appShellRoute = Effect.gen(function* () {
}

const { origin, rows, filters, pagination } = yield* dashboardData;
// The spend line is a read beside the list, never a reason the page fails.
const spend = yield* readSpendNow(env).pipe(Effect.option);
const html = renderDashboard({
origin,
rows,
filters,
pagination,
nowMs: Date.now(),
repoSlug: REPO_SLUG,
...(Option.isSome(spend) ? { spendLine: spendLine(spend.value) } : {}),
});
return HttpServerResponse.raw(html, {
status: 200,
Expand All @@ -396,8 +400,13 @@ const dashboardJsonRoute = Effect.gen(function* () {
if (request.method !== "GET") return methodNotAllowed;

const { origin, rows, filters, pagination } = yield* dashboardData;
const env = yield* CurrentEnv;
const spend = yield* readSpendNow(env).pipe(
Effect.map((status) => ({ ...spendView(status), line: spendLine(status) })),
Effect.orElseSucceed(() => null),
);
return HttpServerResponse.raw(
JSON.stringify({ origin, repoSlug: REPO_SLUG, rows, filters, pagination }),
JSON.stringify({ origin, repoSlug: REPO_SLUG, rows, filters, pagination, spend }),
{
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
Expand Down Expand Up @@ -585,6 +594,12 @@ const router = baseRouter.pipe(
),
HttpRouter.all("/v1/dashboard.json", dashboardJsonRoute),
HttpRouter.all("/v1/analytics.json", analyticsJsonRoute),
// The spend guard's month-to-date view (ADR-0006) — Access-gated like the
// executions and analytics reads.
HttpRouter.all(
"/api/spend",
route("GET", ({ env }) => handleSpend(env), { viewer: true }),
),
// Container-side OTP read — TOKEN-ONLY (no `viewer: true`): a sandbox can't
// carry an Access JWT, so the expiring capability token + burn-after-read are
// the gate. See routes/mailbox.ts for the posture.
Expand Down
129 changes: 129 additions & 0 deletions apps/dispatcher/src/routes/spend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// The spend guard's reader-facing surfaces: a budget or ceiling skip concludes
// the check neutral, and `GET /api/spend` / the dashboard line project one
// spend read (specs/adr/0006-spend-guard.md).

import { Exit } from "effect";
import { describe, expect, it } from "vitest";
import {
decideBudget,
resolveSpendLimits,
RunSkipped,
spendCeilingReason,
spendMonth,
} from "@fractalboxdev/flare-dispatch-core";
import type { SpendStatus } from "@fractalboxdev/flare-dispatch-runtime-cf/spend";
import { runSkippedReason } from "../failure-summary";
import { handleRequest } from "../router";
import { makeFakeD1, makeFakeEnv, makeFakeR2, makeFakeWorkflow } from "../test-helpers";
import { checkConclusion } from "../terminal-verdict";
import { spendLine, spendView } from "./spend";

const NOW = Date.UTC(2026, 9, 22, 12, 0);

const status = (monthToDateMicroUsd: number, admittedMicroUsdPerHour = 401_040): SpendStatus => {
const limits = resolveSpendLimits({});
return {
month: spendMonth(NOW),
limits,
reading: {
finishedMicroUsd: monthToDateMicroUsd,
inFlightMicroUsd: 0,
admittedMicroUsdPerHour,
admittedCount: 1,
},
monthToDateMicroUsd,
decision: decideBudget(monthToDateMicroUsd, limits),
};
};

describe("a spend skip concludes neutral", () => {
it("records a budget skip as skipped with its reason, and a neutral check", () => {
const decision = decideBudget(50_500_000, resolveSpendLimits({}));
if (decision._kind !== "closed") throw new Error("expected a closed budget");
const exit = Exit.fail(new RunSkipped({ reason: decision.reason }));
expect(runSkippedReason(exit)).toBe("spend budget reached: $50.50 of $50.00 this month");
expect(checkConclusion("skipped", true)).toBe("neutral");
});

it("records a burn-ceiling queue timeout as a skip", () => {
const reason = spendCeilingReason({
admittedMicroUsdPerHour: 1_203_120,
ownMicroUsdPerHour: 401_040,
burnMaxMicroUsdPerHour: 1_500_000,
});
expect(runSkippedReason(Exit.fail(new RunSkipped({ reason })))).toMatch(/^spend ceiling: /);
});
});

describe("spendView", () => {
it("returns month-to-date spend, the limits, the admitted burn and the reset", () => {
expect(spendView(status(12_340_000))).toEqual({
month: "2026-10",
monthToDateUsd: 12.34,
budgetUsd: 50,
burnMaxUsdPerHour: 1.5,
admittedUsdPerHour: 0.40104,
admittedCount: 1,
overheadFactor: 1.1,
warnRatio: 0.8,
budgetReached: false,
resetsAt: "2026-11-01T00:00:00.000Z",
});
});

it("reports an unlimited limit as null", () => {
const open = status(1_000_000);
const view = spendView({
...open,
limits: { ...open.limits, budgetMicroUsd: null, burnMaxMicroUsdPerHour: null },
});
expect(view).toMatchObject({ budgetUsd: null, burnMaxUsdPerHour: null });
});
});

describe("GET /api/spend", () => {
const env = (viewerAccessMode?: "required") =>
makeFakeEnv({
hmacSecret: "s",
workflow: makeFakeWorkflow(),
storage: makeFakeR2(),
metadata: makeFakeD1({ executions: [] }),
...(viewerAccessMode !== undefined ? { viewerAccessMode } : {}),
});

it("returns the deploy's spend view under the code defaults", async () => {
const res = await handleRequest(new Request("https://dispatcher.example.com/api/spend"), env());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
monthToDateUsd: 0,
budgetUsd: 50,
burnMaxUsdPerHour: 1.5,
budgetReached: false,
});
});

it("is a GET viewer surface: Access gates it first, then the method", async () => {
const gated = await handleRequest(
new Request("https://dispatcher.example.com/api/spend"),
env("required"),
);
expect(gated.status).toBe(503);
const posted = await handleRequest(
new Request("https://dispatcher.example.com/api/spend", { method: "POST" }),
env(),
);
expect(posted.status).toBe(405);
});
});

describe("spendLine", () => {
it("is the dashboard's one-line summary", () => {
expect(spendLine(status(12_340_000))).toBe(
"Spend 2026-10: $12.34 of $50.00 · burn $0.40/h of $1.50/h · resets 2026-11-01",
);
});

it("says when the budget closed", () => {
expect(spendLine(status(55_000_000))).toContain("$55.00 of $50.00 — budget reached, runs skip");
});
});
92 changes: 92 additions & 0 deletions apps/dispatcher/src/routes/spend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// FlareDispatch Dispatcher — `GET /api/spend`: the spend guard's month-to-date view.
//
// Returns the deploy's month-to-date spend, the monthly budget, the burn
// ceiling, the admitted hourly rate and when the month resets — the same
// numbers admission decides with (runtime-cf spend-d1.ts). The dashboard
// renders `spendLine` of the same view. Access-gated like the executions and
// analytics reads.
//
// Spec: specs/adr/0006-spend-guard.md § Warning and visibility.

import { Effect } from "effect";
import { MICRO_USD_PER_USD, formatUsd } from "@fractalboxdev/flare-dispatch-core";
import { readSpendLimits, readSpendStatus, type SpendStatus } from "@fractalboxdev/flare-dispatch-runtime-cf/spend";
import type { Env } from "../env";

/** The JSON body of `GET /api/spend`. USD figures; `null` = unlimited. */
export type SpendView = {
/** The UTC calendar month, `YYYY-MM`. */
readonly month: string;
readonly monthToDateUsd: number;
readonly budgetUsd: number | null;
readonly burnMaxUsdPerHour: number | null;
/** The summed hourly rate of every admitted execution, across all pools. */
readonly admittedUsdPerHour: number;
readonly admittedCount: number;
readonly overheadFactor: number;
readonly warnRatio: number;
/** `true` once month-to-date spend reaches the budget: runs skip. */
readonly budgetReached: boolean;
/** The first instant of the next UTC month, ISO 8601. */
readonly resetsAt: string;
};

const usd = (microUsd: number): number => microUsd / MICRO_USD_PER_USD;

/** Project a spend read onto the API view. Pure. */
export const spendView = (status: SpendStatus): SpendView => ({
month: status.month.key,
monthToDateUsd: usd(status.monthToDateMicroUsd),
budgetUsd: status.limits.budgetMicroUsd === null ? null : usd(status.limits.budgetMicroUsd),
burnMaxUsdPerHour:
status.limits.burnMaxMicroUsdPerHour === null ? null : usd(status.limits.burnMaxMicroUsdPerHour),
admittedUsdPerHour: usd(status.reading.admittedMicroUsdPerHour),
admittedCount: status.reading.admittedCount,
overheadFactor: status.limits.overheadFactor,
warnRatio: status.limits.warnRatio,
budgetReached: status.decision._kind === "closed",
resetsAt: new Date(status.month.resetsAtMs).toISOString(),
});

/** The one-line summary the dashboard shows. Pure. */
export const spendLine = (status: SpendStatus): string => {
const budget = status.limits.budgetMicroUsd;
const ceiling = status.limits.burnMaxMicroUsdPerHour;
return [
`Spend ${status.month.key}: ${formatUsd(status.monthToDateMicroUsd)} of ${
budget === null ? "unlimited" : formatUsd(budget)
}${status.decision._kind === "closed" ? " — budget reached, runs skip" : ""}`,
`burn ${formatUsd(status.reading.admittedMicroUsdPerHour)}/h of ${
ceiling === null ? "unlimited" : `${formatUsd(ceiling)}/h`
}`,
`resets ${new Date(status.month.resetsAtMs).toISOString().slice(0, 10)}`,
].join(" · ");
};

/** Read the deploy's spend status now. */
export const readSpendNow = (env: Env, nowMs: number = Date.now()): Effect.Effect<SpendStatus, Error> =>
readSpendLimits(env.CONFIG_KV, env).pipe(
Effect.flatMap((limits) => readSpendStatus(env.RUNS_METADATA, limits, nowMs)),
);

/** `GET /api/spend`. A D1 read failure answers 503 with the reason. */
export const handleSpend = (env: Env, nowMs: number = Date.now()): Promise<Response> =>
Effect.runPromise(
readSpendNow(env, nowMs).pipe(
Effect.map(
(status) =>
new Response(JSON.stringify(spendView(status)), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
}),
),
Effect.catchAll((error) =>
Effect.succeed(
new Response(JSON.stringify({ error: "spend_unreadable", detail: error.message }), {
status: 503,
headers: { "content-type": "application/json" },
}),
),
),
),
);
28 changes: 26 additions & 2 deletions apps/dispatcher/src/sandbox-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,39 @@ const POOL_CLASS = { lean: "RunSandbox", browser: "RunSandboxBrowser", agent: "R
const bound = { RUNS_SANDBOX_BROWSER: browser, RUNS_SANDBOX_AGENT: agent, RUNS_SANDBOX_RELEASE: release };

describe("selectAdmission — the pool a run is metered against and its slot cap", () => {
it("admits no pool past its container's max_instances under the committed vars", () => {
it("keeps every pool's cap below its container's max_instances under the committed vars", () => {
for (const image of ["lean", "browser", "agent", "release"] as const) {
const { pool, cap } = selectAdmission(image, { ...wrangler.vars, ...bound });
expect(pool).toBe(image);
const container = wrangler.containers.find((entry) => entry.class_name === POOL_CLASS[pool])!;
expect(cap, `${pool} pool`).toBeLessThanOrEqual(container.max_instances);
expect(cap, `${pool} pool`).toBeLessThan(container.max_instances);
}
});

it("keeps every pool's cap below its container's max_instances on the code defaults", () => {
for (const image of ["lean", "browser", "agent", "release"] as const) {
const { pool, cap } = selectAdmission(image, bound);
const container = wrangler.containers.find((entry) => entry.class_name === POOL_CLASS[pool])!;
expect(cap, `${pool} pool`).toBeLessThan(container.max_instances);
}
});

it("meters every sandbox container class as one pool", () => {
const sandboxClasses = wrangler.containers
.map((entry) => entry.class_name)
.filter((name) => name.startsWith("RunSandbox"))
.sort();
expect(sandboxClasses).toEqual(Object.values(POOL_CLASS).sort());
});

it("caps the browser pool by ADMISSION_CAP_BROWSER", () => {
expect(wrangler.vars.ADMISSION_CAP_BROWSER).toBeDefined();
expect(selectAdmission("browser", { ADMISSION_CAP: "16", ADMISSION_CAP_BROWSER: "4", ...bound })).toEqual({
pool: "browser",
cap: 4,
});
});

it("caps the release and agent pools at their eight containers", () => {
expect(selectAdmission("release", { ...wrangler.vars, ...bound })).toEqual({ pool: "release", cap: 8 });
expect(selectAdmission("agent", { ...wrangler.vars, ...bound })).toEqual({ pool: "agent", cap: 8 });
Expand Down
Loading
Loading