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: 2 additions & 3 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,10 @@ module.exports = {
to: { path: "^packages/db/(src|dist)/schema(/|$)" },
},
{
name: "gateway-quota-runtime-only-through-do-shell",
name: "quota-runtime-only-through-agent-do-shell",
severity: "error",
from: {
path: "^apps/gateway-worker/src/",
pathNot: "^apps/gateway-worker/src/durable-objects/quota-tracker\\.ts$",
pathNot: "^apps/agent-worker/src/durable-objects/quota-tracker\\.ts$",
},
to: {
path: "^(@cheatcode/billing/quota-runtime|packages/billing/(src|dist)/quota-runtime\\.(js|ts|d\\.ts))$",
Expand Down
20 changes: 16 additions & 4 deletions apps/agent-worker/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# @cheatcode/agent-worker

Agent loop Worker with `AgentRun`, its durable `AgentRunWorkflow` owner,
user-scoped `ProjectSandbox`, and the Daytona sandbox adapter.
user-scoped `ProjectSandbox`, agent-owned `QuotaTracker`, and the Daytona
sandbox adapter.

Each run Durable Object is keyed by run UUID. Each sandbox Durable Object is keyed by a
one-way digest of the internal user UUID, so every project for that user shares one isolated
Expand Down Expand Up @@ -118,13 +119,21 @@ losslessly normalized to at most 64 KiB, and SQLite reads return at most 32 rows
its persisted sequence cursor instead of growing isolate memory.

Composio actions use the app-level `COMPOSIO_API_KEY`, active rows in
`v2_user_integrations`, and the gateway-owned `QuotaTracker` Durable Object before
executing against a user-connected OAuth account.
`v2_user_integrations`, and the local agent-owned `QuotaTracker` Durable Object
before executing against a user-connected OAuth account.

ProjectSandbox records elapsed sandbox-hours to the same `QuotaTracker` as a soft
meter so Settings can show real monthly sandbox consumption without blocking
sandbox file/process work.

Agent code reaches `QuotaTracker` through its local namespace. External quota
access is split across named, property-validated WorkerEntrypoints:
`GatewayQuotaEntrypoint` exposes only `peek`, `history`, and `setLimit` to the
gateway, while `QuotaDeletionEntrypoint` exposes only `deleteAllState` to the
webhooks account-deletion workflow. The Durable Object shell composes
`@cheatcode/billing/quota-runtime`, which owns RPC input validation, SQLite
storage, retention, and alarm behavior.

Postgres is authoritative for user-authored skill metadata and R2 is authoritative
for each versioned skill package. ProjectSandbox mirrors the complete selected
package to `/workspace/.cheatcode/skills/<slug>/` so users can inspect and edit its
Expand Down Expand Up @@ -257,7 +266,10 @@ the same bound while streaming.
- `AgentLifecycleEntrypoint`
- `AgentRun`
- `AgentRunWorkflow`
- `GatewayQuotaEntrypoint`
- `ProjectSandbox`
- `QuotaDeletionEntrypoint`
- `QuotaTracker`

## Code Checks

Expand Down Expand Up @@ -287,7 +299,7 @@ pnpm --filter @cheatcode/agent-worker typecheck
- `OUTPUT_DOWNLOAD_SIGNING_SECRET` (Secrets Store binding)
- `OUTPUT_DOWNLOAD_BASE_URL` (development override; production defaults to the gateway origin)
- `PREVIEW_HOSTNAME` (development override; production derives the canonical app hostname)
- `QUOTA_TRACKER`
- `QUOTA_TRACKER` (local agent-owned Durable Object namespace)
- `R2_AUDIT`
- `R2_OUTPUTS`
- `SANDBOX_STATE`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import type {
QuotaFeature,
QuotaHistoryResult,
QuotaSnapshotResult,
QuotaTrackerRpc,
QuotaTryConsumeResponse,
QuotaUsageResponse,
} from "@cheatcode/types/quota";

/** Gateway-owned Durable Object facade over the worker-only billing runtime. */
export class QuotaTracker extends DurableObject<unknown> {
/** Agent-owned Durable Object facade over the worker-only billing runtime. */
export class QuotaTracker extends DurableObject<unknown> implements QuotaTrackerRpc {
private readonly runtime: QuotaTrackerRuntime;

public constructor(ctx: DurableObjectState, env: unknown) {
Expand Down
58 changes: 58 additions & 0 deletions apps/agent-worker/src/gateway-quota-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { WorkerEntrypoint } from "cloudflare:workers";
import { AgentWorkerEnvSchema } from "@cheatcode/env";
import { toUserId, type UserId } from "@cheatcode/types";
import type {
GatewayQuotaServiceBinding,
QuotaFeature,
QuotaHistoryResult,
QuotaUsageResponse,
} from "@cheatcode/types/quota";
import { z } from "zod";
import type { AgentEnv } from "./agent-env";
import type { QuotaTrackerStub } from "./quota-tracker-binding";

const GatewayQuotaCallerSchema = z.strictObject({
caller: z.literal("gateway"),
capability: z.literal("gateway-quota"),
});

const QuotaUserIdSchema = z.string().uuid().transform(toUserId);
type GatewayQuotaCaller = z.infer<typeof GatewayQuotaCallerSchema>;

/** Quota operations used by gateway usage, activity, and limit-sync routes. */
export class GatewayQuotaEntrypoint
extends WorkerEntrypoint<AgentEnv, GatewayQuotaCaller>
implements GatewayQuotaServiceBinding
{
public history(userId: UserId, feature: QuotaFeature, from: Date): Promise<QuotaHistoryResult> {
return gatewayQuotaStub(this.env, this.ctx.props, userId).history(feature, from);
}

public peek(userId: UserId, feature: QuotaFeature, periodEnd: Date): Promise<QuotaUsageResponse> {
return gatewayQuotaStub(this.env, this.ctx.props, userId).peek(feature, periodEnd);
}

public setLimit(
userId: UserId,
feature: QuotaFeature,
limit: number,
entitlementVersion: number,
): Promise<void> {
return gatewayQuotaStub(this.env, this.ctx.props, userId).setLimit(
feature,
limit,
entitlementVersion,
);
}
}

function gatewayQuotaStub(
env: AgentEnv,
props: GatewayQuotaCaller,
userId: UserId,
): QuotaTrackerStub {
AgentWorkerEnvSchema.parse(env);
GatewayQuotaCallerSchema.parse(props);
const parsedUserId = QuotaUserIdSchema.parse(userId);
return env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${parsedUserId}`));
}
13 changes: 12 additions & 1 deletion apps/agent-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@ import { AgentLifecycleEntrypoint } from "./agent-lifecycle-entrypoint";
import { AgentRun } from "./durable-objects/agent-run";
import { AgentRunWorkflow } from "./durable-objects/agent-run-workflow";
import { ProjectSandbox } from "./durable-objects/project-sandbox";
import { QuotaTracker } from "./durable-objects/quota-tracker";
import { formatAgentRouteError, toAgentRouteError } from "./error-handling";
import { GatewayQuotaEntrypoint } from "./gateway-quota-entrypoint";
import { registerProjectFileHttpRoutes } from "./project-file-http-routes";
import { QuotaDeletionEntrypoint } from "./quota-deletion-entrypoint";
import { registerSandboxPreviewHttpRoutes } from "./sandbox-preview-http-routes";
import { registerSandboxTerminalHttpRoutes } from "./sandbox-terminal-http-routes";
import { registerSkillRuntimeExecutionRoutes } from "./skill-runtime-execution-routes";
import { registerSkillRuntimeManagedRoutes } from "./skill-runtime-managed-routes";
import { registerUserSkillHttpRoutes } from "./user-skill-http-routes";

export { AgentLifecycleEntrypoint, AgentRun, AgentRunWorkflow, ProjectSandbox };
export {
AgentLifecycleEntrypoint,
AgentRun,
AgentRunWorkflow,
GatewayQuotaEntrypoint,
ProjectSandbox,
QuotaDeletionEntrypoint,
QuotaTracker,
};

export const agentApp = new Hono<{ Bindings: AgentEnv }>();

Expand Down
30 changes: 30 additions & 0 deletions apps/agent-worker/src/quota-deletion-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { WorkerEntrypoint } from "cloudflare:workers";
import { AgentWorkerEnvSchema } from "@cheatcode/env";
import { toUserId, type UserId } from "@cheatcode/types";
import type { QuotaDeletionServiceBinding } from "@cheatcode/types/quota";
import { z } from "zod";
import type { AgentEnv } from "./agent-env";

const QuotaDeletionCallerSchema = z.strictObject({
caller: z.literal("webhooks"),
capability: z.literal("quota-deletion"),
});

const QuotaUserIdSchema = z.string().uuid().transform(toUserId);
type QuotaDeletionCaller = z.infer<typeof QuotaDeletionCallerSchema>;

/**
* Destructive quota-state capability held only by the account-deletion worker.
*/
export class QuotaDeletionEntrypoint
extends WorkerEntrypoint<AgentEnv, QuotaDeletionCaller>
implements QuotaDeletionServiceBinding
{
public deleteAllState(userId: UserId): Promise<void> {
AgentWorkerEnvSchema.parse(this.env);
QuotaDeletionCallerSchema.parse(this.ctx.props);
const parsedUserId = QuotaUserIdSchema.parse(userId);
const namespace = this.env.QUOTA_TRACKER;
return namespace.get(namespace.idFromName(`quota:${parsedUserId}`)).deleteAllState();
}
}
7 changes: 5 additions & 2 deletions apps/agent-worker/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@
},
{
"name": "QUOTA_TRACKER",
"class_name": "QuotaTracker",
"script_name": "cheatcode-gateway"
"class_name": "QuotaTracker"
}
]
},
Expand All @@ -73,6 +72,10 @@
{
"tag": "v2",
"new_sqlite_classes": ["ProjectSandbox"]
},
{
"tag": "v3",
"new_sqlite_classes": ["QuotaTracker"]
}
],
"workflows": [
Expand Down
24 changes: 13 additions & 11 deletions apps/gateway-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ entitlement cache outside Postgres, while project and BYOK writes read the
authoritative entitlement row under the same per-user advisory-lock order as
entitlement reconciliation.

`QuotaTracker` supports hard `try-consume` gates for connected-tool calls and
soft `record` metering for sandbox-hours. Limit synchronization carries the
entitlement row's `updatedAt` version, and the Durable Object ignores older
writes so a stale KV or Worker request cannot overwrite a newer plan. Request
rate-limit headers remain the canonical live rate-limit state.
The agent Worker owns `QuotaTracker`. Gateway usage, activity, and limit-sync
routes hold a named `GatewayQuotaEntrypoint` Service Binding that exposes only
`peek`, `history`, and `setLimit`; the gateway has no Durable Object namespace
or destructive quota capability. Limit synchronization carries the entitlement
row's `updatedAt` version, and the Durable Object ignores older writes so a
stale KV or Worker request cannot overwrite a newer plan. Request rate-limit
headers remain the canonical live rate-limit state.
Gateway-native buckets use Hono's registered route path; the resulting key
format replaces the former duplicated literals and old Durable Object buckets
age out naturally. Forwarded-route costs remain owned by the shared manifest.
Expand Down Expand Up @@ -107,10 +109,10 @@ converge. Deployments publish the gateway last so public traffic observes only a
backend set built from the same reviewed revision. SQLite schema validation
remains synchronous.

`IdempotencyStore`, `RateLimiter`, and `QuotaTracker` each own one exact SQLite
schema. New objects initialize that schema directly; existing objects must
already match it before an operation is admitted. Run creation is also durably
idempotent in Postgres, so request-cache state cannot create a duplicate run.
`IdempotencyStore` and `RateLimiter` each own one exact SQLite schema. New
objects initialize that schema directly; existing objects must already match it
before an operation is admitted. Run creation is also durably idempotent in
Postgres, so request-cache state cannot create a duplicate run.

The shared framework-free tool capability catalog in `@cheatcode/types`
statically constrains the Mastra tool registry to the same exact names. Each
Expand All @@ -121,7 +123,6 @@ same traits instead of maintaining parallel tool-name lists.
## Public exports

- `IdempotencyStore`
- `QuotaTracker`
- `RateLimiter`

## Code Checks
Expand All @@ -142,7 +143,8 @@ pnpm --filter @cheatcode/gateway-worker typecheck
- `PREVIEW_PROXY` (generated local-only Service Binding; production preview
traffic reaches the preview Worker through its wildcard route)
- `RATE_LIMITER`
- `QUOTA_TRACKER`
- `QUOTA_TRACKER` (named `GatewayQuotaEntrypoint` Service Binding to
agent-worker; grants only `peek`, `history`, and `setLimit`)
- `IDEMPOTENCY`
- `ENTITLEMENTS_CACHE`
- `HYPERDRIVE` (dedicated config whose database login is exactly `app_gateway`)
Expand Down
11 changes: 5 additions & 6 deletions apps/gateway-worker/src/activity-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@ import {
ActivityHistoryResponseSchema,
ActivityQuerySchema,
} from "@cheatcode/types/api";
import { QUOTA_FEATURES } from "@cheatcode/types/quota";
import { type GatewayQuotaServiceBinding, QUOTA_FEATURES } from "@cheatcode/types/quota";
import type { z } from "zod";
import type { QuotaTracker } from "./durable-objects/quota-tracker";

export interface ActivityRouteEnv {
DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY: WorkerSecret;
HYPERDRIVE: Hyperdrive;
QUOTA_TRACKER: DurableObjectNamespace<QuotaTracker>;
QUOTA_TRACKER: GatewayQuotaServiceBinding;
}

const MS_PER_DAY = 86_400_000;
Expand Down Expand Up @@ -60,10 +59,10 @@ async function buildActivityResponse(
}

async function listSandboxHourHistory(env: ActivityRouteEnv, userId: UserId, days: number) {
const stub = env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`));
let history: Awaited<ReturnType<typeof stub.history>>;
let history: Awaited<ReturnType<GatewayQuotaServiceBinding["history"]>>;
try {
history = await stub.history(
history = await env.QUOTA_TRACKER.history(
userId,
QUOTA_FEATURES.sandboxHours,
new Date(Date.now() - days * MS_PER_DAY),
);
Expand Down
4 changes: 2 additions & 2 deletions apps/gateway-worker/src/gateway-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { DatabaseHandle } from "@cheatcode/db";
import type { CloudflareVersionMetadata, WorkerSecret } from "@cheatcode/env";
import type { AnalyticsBindings } from "@cheatcode/observability";
import type { ResourceDeletionServiceBinding } from "@cheatcode/types/internal";
import type { GatewayQuotaServiceBinding } from "@cheatcode/types/quota";
import type { Context, Hono } from "hono";
import type { IdempotencyStore } from "./durable-objects/idempotency";
import type { QuotaTracker } from "./durable-objects/quota-tracker";
import type { RateLimiter } from "./durable-objects/rate-limiter";
import type { IdempotencyBindings } from "./idempotency";

Expand All @@ -26,7 +26,7 @@ export interface GatewayEnv extends AnalyticsBindings, IdempotencyBindings {
POLAR_PRODUCT_ID_PRO?: string;
POLAR_SERVER?: "production" | "sandbox";
PREVIEW_PROXY?: Fetcher;
QUOTA_TRACKER: DurableObjectNamespace<QuotaTracker>;
QUOTA_TRACKER: GatewayQuotaServiceBinding;
RATE_LIMITER: DurableObjectNamespace<RateLimiter>;
RESOURCE_DELETION: ResourceDeletionServiceBinding;
WEBHOOKS: Fetcher;
Expand Down
3 changes: 1 addition & 2 deletions apps/gateway-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { registerBillingHttpRoutes } from "./billing-http-routes";
import { registerCoreHttpRoutes } from "./core-http-routes";
import { resolveCorsOrigin } from "./cors";
import { IdempotencyStore } from "./durable-objects/idempotency";
import { QuotaTracker } from "./durable-objects/quota-tracker";
import { RateLimiter } from "./durable-objects/rate-limiter";
import { formatGatewayRouteError } from "./error-handling";
import type { GatewayContext, GatewayEnv, GatewayHonoEnv } from "./gateway-env";
Expand All @@ -31,7 +30,7 @@ import { registerProviderHttpRoutes } from "./provider-http-routes";
import { withRateLimitErrorHeaders } from "./rate-limit";
import { registerSearchHttpRoutes } from "./search-http-routes";

export { IdempotencyStore, QuotaTracker, RateLimiter };
export { IdempotencyStore, RateLimiter };

const CORS_EXPOSED_HEADERS = [
"Content-Disposition",
Expand Down
Loading