Skip to content

Commit 625cf30

Browse files
committed
refactor: relocate quota tracker DO to agent worker behind capability entrypoints
Move the QuotaTracker Durable Object from gateway-worker to agent-worker, where its consumers (run admission, Composio metering, accrual flush) live. Cross-worker access now goes through capability-scoped WorkerEntrypoints with strict-Zod ctx.props validation instead of full cross-script DO namespaces: - agent-worker: local QUOTA_TRACKER binding + appended new_sqlite_classes v3; GatewayQuotaEntrypoint {history, peek, setLimit} and QuotaDeletionEntrypoint {deleteAllState} exported as named entrypoints - gateway-worker: class export + local binding removed; service binding to GatewayQuotaEntrypoint; appended deleted_classes v4 (fires only after all bindings/code refs are gone) - webhooks-worker: cross-script DO binding replaced by QUOTA_DELETION service binding scoped to deleteAllState only - dependency-cruiser: quota-runtime imports restricted to the agent DO shell (rule proven firing via probe) Net privilege reduction: gateway/webhooks previously held full 8-method DO namespaces; each is now confined to its exact baseline call set. All 503 service_maintenance_unavailable translations preserved at every call site. Deploy is roll-forward-only per the approved rev-5.2 sequence (agent -> webhooks -> gateway). Pre-cutover quota counters are discarded at the gateway deploy; sanctioned pre-launch with disposable data.
1 parent dce5ca1 commit 625cf30

23 files changed

Lines changed: 222 additions & 87 deletions

.dependency-cruiser.cjs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,10 @@ module.exports = {
5757
to: { path: "^packages/db/(src|dist)/schema(/|$)" },
5858
},
5959
{
60-
name: "gateway-quota-runtime-only-through-do-shell",
60+
name: "quota-runtime-only-through-agent-do-shell",
6161
severity: "error",
6262
from: {
63-
path: "^apps/gateway-worker/src/",
64-
pathNot: "^apps/gateway-worker/src/durable-objects/quota-tracker\\.ts$",
63+
pathNot: "^apps/agent-worker/src/durable-objects/quota-tracker\\.ts$",
6564
},
6665
to: {
6766
path: "^(@cheatcode/billing/quota-runtime|packages/billing/(src|dist)/quota-runtime\\.(js|ts|d\\.ts))$",

apps/agent-worker/README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# @cheatcode/agent-worker
22

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

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

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

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

129+
Agent code reaches `QuotaTracker` through its local namespace. External quota
130+
access is split across named, property-validated WorkerEntrypoints:
131+
`GatewayQuotaEntrypoint` exposes only `peek`, `history`, and `setLimit` to the
132+
gateway, while `QuotaDeletionEntrypoint` exposes only `deleteAllState` to the
133+
webhooks account-deletion workflow. The Durable Object shell composes
134+
`@cheatcode/billing/quota-runtime`, which owns RPC input validation, SQLite
135+
storage, retention, and alarm behavior.
136+
128137
Postgres is authoritative for user-authored skill metadata and R2 is authoritative
129138
for each versioned skill package. ProjectSandbox mirrors the complete selected
130139
package to `/workspace/.cheatcode/skills/<slug>/` so users can inspect and edit its
@@ -257,7 +266,10 @@ the same bound while streaming.
257266
- `AgentLifecycleEntrypoint`
258267
- `AgentRun`
259268
- `AgentRunWorkflow`
269+
- `GatewayQuotaEntrypoint`
260270
- `ProjectSandbox`
271+
- `QuotaDeletionEntrypoint`
272+
- `QuotaTracker`
261273

262274
## Code Checks
263275

@@ -287,7 +299,7 @@ pnpm --filter @cheatcode/agent-worker typecheck
287299
- `OUTPUT_DOWNLOAD_SIGNING_SECRET` (Secrets Store binding)
288300
- `OUTPUT_DOWNLOAD_BASE_URL` (development override; production defaults to the gateway origin)
289301
- `PREVIEW_HOSTNAME` (development override; production derives the canonical app hostname)
290-
- `QUOTA_TRACKER`
302+
- `QUOTA_TRACKER` (local agent-owned Durable Object namespace)
291303
- `R2_AUDIT`
292304
- `R2_OUTPUTS`
293305
- `SANDBOX_STATE`

apps/gateway-worker/src/durable-objects/quota-tracker.ts renamed to apps/agent-worker/src/durable-objects/quota-tracker.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import type {
44
QuotaFeature,
55
QuotaHistoryResult,
66
QuotaSnapshotResult,
7+
QuotaTrackerRpc,
78
QuotaTryConsumeResponse,
89
QuotaUsageResponse,
910
} from "@cheatcode/types/quota";
1011

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

1516
public constructor(ctx: DurableObjectState, env: unknown) {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { WorkerEntrypoint } from "cloudflare:workers";
2+
import { AgentWorkerEnvSchema } from "@cheatcode/env";
3+
import { toUserId, type UserId } from "@cheatcode/types";
4+
import type {
5+
GatewayQuotaServiceBinding,
6+
QuotaFeature,
7+
QuotaHistoryResult,
8+
QuotaUsageResponse,
9+
} from "@cheatcode/types/quota";
10+
import { z } from "zod";
11+
import type { AgentEnv } from "./agent-env";
12+
import type { QuotaTrackerStub } from "./quota-tracker-binding";
13+
14+
const GatewayQuotaCallerSchema = z.strictObject({
15+
caller: z.literal("gateway"),
16+
capability: z.literal("gateway-quota"),
17+
});
18+
19+
const QuotaUserIdSchema = z.string().uuid().transform(toUserId);
20+
type GatewayQuotaCaller = z.infer<typeof GatewayQuotaCallerSchema>;
21+
22+
/** Quota operations used by gateway usage, activity, and limit-sync routes. */
23+
export class GatewayQuotaEntrypoint
24+
extends WorkerEntrypoint<AgentEnv, GatewayQuotaCaller>
25+
implements GatewayQuotaServiceBinding
26+
{
27+
public history(userId: UserId, feature: QuotaFeature, from: Date): Promise<QuotaHistoryResult> {
28+
return gatewayQuotaStub(this.env, this.ctx.props, userId).history(feature, from);
29+
}
30+
31+
public peek(userId: UserId, feature: QuotaFeature, periodEnd: Date): Promise<QuotaUsageResponse> {
32+
return gatewayQuotaStub(this.env, this.ctx.props, userId).peek(feature, periodEnd);
33+
}
34+
35+
public setLimit(
36+
userId: UserId,
37+
feature: QuotaFeature,
38+
limit: number,
39+
entitlementVersion: number,
40+
): Promise<void> {
41+
return gatewayQuotaStub(this.env, this.ctx.props, userId).setLimit(
42+
feature,
43+
limit,
44+
entitlementVersion,
45+
);
46+
}
47+
}
48+
49+
function gatewayQuotaStub(
50+
env: AgentEnv,
51+
props: GatewayQuotaCaller,
52+
userId: UserId,
53+
): QuotaTrackerStub {
54+
AgentWorkerEnvSchema.parse(env);
55+
GatewayQuotaCallerSchema.parse(props);
56+
const parsedUserId = QuotaUserIdSchema.parse(userId);
57+
return env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${parsedUserId}`));
58+
}

apps/agent-worker/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,26 @@ import { AgentLifecycleEntrypoint } from "./agent-lifecycle-entrypoint";
1515
import { AgentRun } from "./durable-objects/agent-run";
1616
import { AgentRunWorkflow } from "./durable-objects/agent-run-workflow";
1717
import { ProjectSandbox } from "./durable-objects/project-sandbox";
18+
import { QuotaTracker } from "./durable-objects/quota-tracker";
1819
import { formatAgentRouteError, toAgentRouteError } from "./error-handling";
20+
import { GatewayQuotaEntrypoint } from "./gateway-quota-entrypoint";
1921
import { registerProjectFileHttpRoutes } from "./project-file-http-routes";
22+
import { QuotaDeletionEntrypoint } from "./quota-deletion-entrypoint";
2023
import { registerSandboxPreviewHttpRoutes } from "./sandbox-preview-http-routes";
2124
import { registerSandboxTerminalHttpRoutes } from "./sandbox-terminal-http-routes";
2225
import { registerSkillRuntimeExecutionRoutes } from "./skill-runtime-execution-routes";
2326
import { registerSkillRuntimeManagedRoutes } from "./skill-runtime-managed-routes";
2427
import { registerUserSkillHttpRoutes } from "./user-skill-http-routes";
2528

26-
export { AgentLifecycleEntrypoint, AgentRun, AgentRunWorkflow, ProjectSandbox };
29+
export {
30+
AgentLifecycleEntrypoint,
31+
AgentRun,
32+
AgentRunWorkflow,
33+
GatewayQuotaEntrypoint,
34+
ProjectSandbox,
35+
QuotaDeletionEntrypoint,
36+
QuotaTracker,
37+
};
2738

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { WorkerEntrypoint } from "cloudflare:workers";
2+
import { AgentWorkerEnvSchema } from "@cheatcode/env";
3+
import { toUserId, type UserId } from "@cheatcode/types";
4+
import type { QuotaDeletionServiceBinding } from "@cheatcode/types/quota";
5+
import { z } from "zod";
6+
import type { AgentEnv } from "./agent-env";
7+
8+
const QuotaDeletionCallerSchema = z.strictObject({
9+
caller: z.literal("webhooks"),
10+
capability: z.literal("quota-deletion"),
11+
});
12+
13+
const QuotaUserIdSchema = z.string().uuid().transform(toUserId);
14+
type QuotaDeletionCaller = z.infer<typeof QuotaDeletionCallerSchema>;
15+
16+
/**
17+
* Destructive quota-state capability held only by the account-deletion worker.
18+
*/
19+
export class QuotaDeletionEntrypoint
20+
extends WorkerEntrypoint<AgentEnv, QuotaDeletionCaller>
21+
implements QuotaDeletionServiceBinding
22+
{
23+
public deleteAllState(userId: UserId): Promise<void> {
24+
AgentWorkerEnvSchema.parse(this.env);
25+
QuotaDeletionCallerSchema.parse(this.ctx.props);
26+
const parsedUserId = QuotaUserIdSchema.parse(userId);
27+
const namespace = this.env.QUOTA_TRACKER;
28+
return namespace.get(namespace.idFromName(`quota:${parsedUserId}`)).deleteAllState();
29+
}
30+
}

apps/agent-worker/wrangler.jsonc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@
6060
},
6161
{
6262
"name": "QUOTA_TRACKER",
63-
"class_name": "QuotaTracker",
64-
"script_name": "cheatcode-gateway"
63+
"class_name": "QuotaTracker"
6564
}
6665
]
6766
},
@@ -73,6 +72,10 @@
7372
{
7473
"tag": "v2",
7574
"new_sqlite_classes": ["ProjectSandbox"]
75+
},
76+
{
77+
"tag": "v3",
78+
"new_sqlite_classes": ["QuotaTracker"]
7679
}
7780
],
7881
"workflows": [

apps/gateway-worker/README.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ entitlement cache outside Postgres, while project and BYOK writes read the
2929
authoritative entitlement row under the same per-user advisory-lock order as
3030
entitlement reconciliation.
3131

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

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

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

123125
- `IdempotencyStore`
124-
- `QuotaTracker`
125126
- `RateLimiter`
126127

127128
## Code Checks
@@ -142,7 +143,8 @@ pnpm --filter @cheatcode/gateway-worker typecheck
142143
- `PREVIEW_PROXY` (generated local-only Service Binding; production preview
143144
traffic reaches the preview Worker through its wildcard route)
144145
- `RATE_LIMITER`
145-
- `QUOTA_TRACKER`
146+
- `QUOTA_TRACKER` (named `GatewayQuotaEntrypoint` Service Binding to
147+
agent-worker; grants only `peek`, `history`, and `setLimit`)
146148
- `IDEMPOTENCY`
147149
- `ENTITLEMENTS_CACHE`
148150
- `HYPERDRIVE` (dedicated config whose database login is exactly `app_gateway`)

apps/gateway-worker/src/activity-routes.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,13 @@ import {
1313
ActivityHistoryResponseSchema,
1414
ActivityQuerySchema,
1515
} from "@cheatcode/types/api";
16-
import { QUOTA_FEATURES } from "@cheatcode/types/quota";
16+
import { type GatewayQuotaServiceBinding, QUOTA_FEATURES } from "@cheatcode/types/quota";
1717
import type { z } from "zod";
18-
import type { QuotaTracker } from "./durable-objects/quota-tracker";
1918

2019
export interface ActivityRouteEnv {
2120
DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY: WorkerSecret;
2221
HYPERDRIVE: Hyperdrive;
23-
QUOTA_TRACKER: DurableObjectNamespace<QuotaTracker>;
22+
QUOTA_TRACKER: GatewayQuotaServiceBinding;
2423
}
2524

2625
const MS_PER_DAY = 86_400_000;
@@ -60,10 +59,10 @@ async function buildActivityResponse(
6059
}
6160

6261
async function listSandboxHourHistory(env: ActivityRouteEnv, userId: UserId, days: number) {
63-
const stub = env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`));
64-
let history: Awaited<ReturnType<typeof stub.history>>;
62+
let history: Awaited<ReturnType<GatewayQuotaServiceBinding["history"]>>;
6563
try {
66-
history = await stub.history(
64+
history = await env.QUOTA_TRACKER.history(
65+
userId,
6766
QUOTA_FEATURES.sandboxHours,
6867
new Date(Date.now() - days * MS_PER_DAY),
6968
);

apps/gateway-worker/src/gateway-env.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import type { DatabaseHandle } from "@cheatcode/db";
22
import type { CloudflareVersionMetadata, WorkerSecret } from "@cheatcode/env";
33
import type { AnalyticsBindings } from "@cheatcode/observability";
44
import type { ResourceDeletionServiceBinding } from "@cheatcode/types/internal";
5+
import type { GatewayQuotaServiceBinding } from "@cheatcode/types/quota";
56
import type { Context, Hono } from "hono";
67
import type { IdempotencyStore } from "./durable-objects/idempotency";
7-
import type { QuotaTracker } from "./durable-objects/quota-tracker";
88
import type { RateLimiter } from "./durable-objects/rate-limiter";
99
import type { IdempotencyBindings } from "./idempotency";
1010

@@ -26,7 +26,7 @@ export interface GatewayEnv extends AnalyticsBindings, IdempotencyBindings {
2626
POLAR_PRODUCT_ID_PRO?: string;
2727
POLAR_SERVER?: "production" | "sandbox";
2828
PREVIEW_PROXY?: Fetcher;
29-
QUOTA_TRACKER: DurableObjectNamespace<QuotaTracker>;
29+
QUOTA_TRACKER: GatewayQuotaServiceBinding;
3030
RATE_LIMITER: DurableObjectNamespace<RateLimiter>;
3131
RESOURCE_DELETION: ResourceDeletionServiceBinding;
3232
WEBHOOKS: Fetcher;

0 commit comments

Comments
 (0)