History

Wire execution guards into the metered engine decorator

Order: rate-limit backstop first (cheap counter), then the balance gate, then usage tracking. Guards wrap outside the tracker so a blocked execution is neither run nor tracked. Covers both planes (HTTP executor plane and MCP session DO) since both build engines through this layer.

Rhys Sullivan committed 4 months ago

Commit bc95e261 parent 1d8d9d01 file · +30 −9Browse files

Unified · Split

apps/cloud/src/engine/execution-stack-metered.ts+30−9
@@ -28,19 +28,40 @@ import { · 27 unmodified lines
28import { AutumnService } from "../extensions/billing/service";28import { AutumnService } from "../extensions/billing/service";
29import type { DbService } from "../db/db";29import type { DbService } from "../db/db";
30import { CloudExecutionSeamsLayer } from "../engine/execution-stack";30import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
31import { makeExecutionLimitGate } from "./execution-gate";
32import { makeCloudExecutionRateLimiter } from "./execution-rate-limit";
31import { withExecutionUsageTracking } from "./execution-usage";33import { withExecutionUsageTracking } from "./execution-usage";
32 34
33// Usage-metering decorator bound to the billing service. `trackExecution` is35// Usage-metering decorator bound to the billing service, plus the two
34// fire-and-forget (`Effect.runFork`) so the billing call can't stall a36// pre-execution guards this layer owns, ordered cheapest first:
35// user-facing execution.37// 1. rate-limit backstop (counter DO, independent of billing)
38// 2. execution balance gate (Autumn check, cached 60s, fails open)
39// 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing
40// call can't stall a user-facing execution.
41// The guards wrap OUTSIDE the tracker so a blocked execution is neither run
42// nor tracked. One gate/limiter instance per layer build: the balance cache is
43// shared by every engine this decorator produces (in the MCP session DO that's
44// the session's engines; on the HTTP plane the boot layer builds it once).
36export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> =45export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> =
37 Layer.effect(EngineDecorator)(46 Layer.effect(EngineDecorator)(
38 Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => ({47 Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => {
39 decorate: (engine, identity: EngineStackIdentity) =>48 const balanceGate = makeExecutionLimitGate((organizationId) =>
40 withExecutionUsageTracking(identity.organizationId, engine, (organizationId) =>49 autumn.checkExecutionBalance(organizationId),
41 Effect.runFork(autumn.trackExecution(organizationId)),50 );
42 ),51 const rateLimiter = makeCloudExecutionRateLimiter();
43 })),52 return {
53 decorate: (engine, identity: EngineStackIdentity) =>
54 rateLimiter.decorate(
55 identity.organizationId,
56 balanceGate.decorate(
57 identity.organizationId,
58 withExecutionUsageTracking(identity.organizationId, engine, (organizationId) =>
59 Effect.runFork(autumn.trackExecution(organizationId)),
60 ),
61 ),
62 ),
63 };
64 }),
44 );65 );
45 66
46/**67/**