// --------------------------------------------------------------------------- // Pre-execution balance gate — blocks new executions once an organization has // used up its plan's included executions. // // Usage is tracked to Autumn after every execution (execution-usage.ts), but // nothing ever CHECKED the balance before running, so a free-tier org could run // unbounded executions past its quota. This gate consults // `AutumnService.checkExecutionBalance` before `execute` / `executeWithPause`. // `resume` is never gated: a paused execution already consumed its quota slot // when it started, and blocking resume would strand approved work forever. // // FAIL OPEN is a hard requirement: any Autumn error, timeout, or missing // customer/feature allows the execution (logged + Sentry, mirroring // `trackExecution`'s reporting). Autumn being down must never block // executions or add meaningful latency, so the check is bounded by a short // timeout and its outcome is cached per organization. // // Error surfacing: the gate fails internally with the typed // `ExecutionLimitReachedError`, and the engine seam folds it into the // descriptive `ExecuteResult.error` channel. That is the codebase's mechanism // for getting a domain failure's message to the MCP client verbatim — engine // error-channel failures are deliberately rendered opaque by the MCP host // ("Internal tool error [correlation id]"), exactly like the runtimes fold // `CodeCompilationError` / `SandboxRuntimeError` into `ExecuteResult.error`. // --------------------------------------------------------------------------- import { Data, Effect } from "effect"; import type * as Cause from "effect/Cause"; import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; import { captureCauseEffect } from "../observability"; import { EXECUTION_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; // The engine's completed-result payload (`ExecuteResult` in codemode-core), // derived from the public execution types so this app package doesn't need a // direct dependency on the kernel package that declares it. type EngineExecuteResult = Extract["result"]; // One check per org per minute is fresh enough for a monthly quota; both // allowed AND blocked outcomes are cached (errors never are). const BALANCE_CACHE_TTL_MS = 60_000; // Autumn slower than this => fail open rather than stall a user-facing // execution behind the billing provider. const BALANCE_CHECK_TIMEOUT_MS = 2_000; // Sweep guard so a long-lived worker isolate serving many orgs can't grow the // cache map unbounded. const BALANCE_CACHE_MAX_ENTRIES = 10_000; export { EXECUTION_LIMIT_BLOCKED_MESSAGE }; export class ExecutionLimitReachedError extends Data.TaggedError("ExecutionLimitReachedError")<{ readonly organizationId: string; readonly message: string; }> {} /** Internal sentinel for a balance check that exceeded its time budget. */ class GateCheckTimeoutError extends Data.TaggedError("GateCheckTimeoutError")<{ readonly timeoutMs: number; }> {} // --------------------------------------------------------------------------- // Shared gate seam — used by this gate and the rate-limit backstop. // --------------------------------------------------------------------------- export type GateDecision = | { readonly blocked: false } | { readonly blocked: true; readonly error: { readonly message: string } }; /** * Wrap an engine so `decide` runs before `execute` / `executeWithPause`. A * blocked decision short-circuits to a descriptive `ExecuteResult.error` * (which `formatExecuteResult` renders as a clean `isError` MCP tool result) * WITHOUT invoking the inner engine — so a blocked execution is neither run * nor usage-tracked. `resume` and all read-only members pass through * untouched: paused executions must always be able to complete. */ export const withPreExecutionGate = ( engine: ExecutionEngine, decide: Effect.Effect, ): ExecutionEngine => ({ execute: (code, options) => Effect.flatMap( decide, (decision): Effect.Effect => decision.blocked ? Effect.succeed({ result: null, error: decision.error.message }) : engine.execute(code, options), ), executeWithPause: (code, options) => Effect.flatMap( decide, (decision): Effect.Effect => decision.blocked ? Effect.succeed({ status: "completed", result: { result: null, error: decision.error.message }, }) : engine.executeWithPause(code, options), ), // resume is never gated: paused executions must be able to complete. resume: (executionId, response) => engine.resume(executionId, response), // Optional member, so it must be forwarded explicitly — a decorator that // rebuilds the object literal drops it, and the host then reads every settled // execution as "never existed". isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), getDescription: engine.getDescription, }); // --------------------------------------------------------------------------- // Balance gate factory // --------------------------------------------------------------------------- export type ExecutionBalanceCheck = ( organizationId: string, ) => Effect.Effect<{ readonly allowed: boolean }, unknown>; /** * Build a balance gate around `checkBalance` (in production: * `AutumnService.checkExecutionBalance`). One gate instance holds one * per-organization outcome cache; the metered decorator layer creates a single * instance so all engines it decorates share it. */ export const makeExecutionLimitGate = (checkBalance: ExecutionBalanceCheck) => { const timeoutMs = BALANCE_CHECK_TIMEOUT_MS; const cache = new Map(); const writeCache = (organizationId: string, allowed: boolean, nowMs: number): void => { if (cache.size >= BALANCE_CACHE_MAX_ENTRIES) { for (const [key, entry] of cache) { if (entry.expiresAtMs <= nowMs) cache.delete(key); } // Still saturated after dropping expired entries: reset rather than grow. if (cache.size >= BALANCE_CACHE_MAX_ENTRIES) cache.clear(); } cache.set(organizationId, { allowed, expiresAtMs: nowMs + BALANCE_CACHE_TTL_MS }); }; const toDecision = (organizationId: string, allowed: boolean): GateDecision => allowed ? { blocked: false } : { blocked: true, error: new ExecutionLimitReachedError({ organizationId, message: EXECUTION_LIMIT_BLOCKED_MESSAGE, }), }; const decide = (organizationId: string): Effect.Effect => Effect.suspend(() => { const nowMs = Date.now(); const cached = cache.get(organizationId); if (cached && cached.expiresAtMs > nowMs) { return Effect.succeed(toDecision(organizationId, cached.allowed)); } return checkBalance(organizationId).pipe( Effect.timeoutOrElse({ duration: `${timeoutMs} millis`, orElse: () => Effect.fail(new GateCheckTimeoutError({ timeoutMs })), }), Effect.map(({ allowed }) => { writeCache(organizationId, allowed, nowMs); return toDecision(organizationId, allowed); }), // FAIL OPEN: Autumn errors, timeouts, and missing customers/features // must never block executions. Reported like `trackExecution` so a // billing outage still pages; the error outcome is never cached. Effect.catch((error: unknown) => Effect.gen(function* () { yield* Effect.sync(() => { console.warn("[billing] execution balance check failed open:", error); }); yield* captureCauseEffect(error); return { blocked: false } as const satisfies GateDecision; }), ), ); }); return { /** Wrap an engine so the balance gate runs before each new execution. */ decorate: ( organizationId: string, engine: ExecutionEngine, ): ExecutionEngine => withPreExecutionGate(engine, decide(organizationId)), }; };