diff --git a/.dockerignore b/.dockerignore index 118585b6..246ebb3d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,4 @@ *.log /*.png **/*.tsbuildinfo -coverage node_modules -qa-output -supabase/.temp diff --git a/.editorconfig b/.editorconfig index 15cf692a..6b7f9e3f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,11 +11,5 @@ indent_size = 2 [*.py] indent_size = 4 -[*.toml] -indent_size = 4 - [*.md] trim_trailing_whitespace = false - -[Makefile] -indent_style = tab diff --git a/.gitignore b/.gitignore index d5bdec05..639957bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,7 @@ node_modules/ .next/ .vercel/ dist/ -coverage/ .qa/ -qa-output/ -qa-*.png -*.har *.tsbuildinfo .env .env.* diff --git a/.vercelignore b/.vercelignore index bc48339e..dda7365a 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1,7 +1,3 @@ -# Local documentation and planning directories are not part of the deploy bundle. -/docs/ -/tasks/ -/plans/ # Build artifacts (match anywhere; Vercel installs + builds fresh) node_modules .pnpm-store @@ -17,4 +13,3 @@ node_modules compose.yaml .dockerignore infra/containers/dev -supabase diff --git a/AGENTS.md b/AGENTS.md index a23ad7ee..05e48094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,8 @@ apps/ Deployable services webhooks-worker/ Polar/Clerk/Composio webhooks + internal ops workflows packages/ Shared libraries - agent-core/ Mastra instance + workflows - tools-*/ Tool implementations per domain + agent-core/ Mastra instance, workflows, and data/document/media tools + tools-{browser,code,research}/ Multi-consumer and isolated tool-domain packages db/ Drizzle schema (per-domain) + queries + migrations byok/ Vault-backed BYOK key store skills/ Build-time skill bundler @@ -49,9 +49,9 @@ packages/ Shared libraries types/ Zod schemas + branded IDs + InferAgentUIMessage ui/ Shared Cheatcode UI primitives, icon barrel, AI response renderer -skills/ 8 curated Anthropic SKILL.md skills -infra/ Wrangler configs, Supabase migrations, Daytona sandbox Dockerfile -scripts/ Operational helpers only: build skills, local startup, deploy orchestration, migrations +skills/ 19 curated skills; _shared/office is vendored and not bundled +infra/ Container images and Dockerfiles for local development and Daytona sandboxes +scripts/ Operational helpers: build-skills, dev, dev-worker-config, jsonc, migrate, migration-drizzle, database-operation-safety ``` ## Build @@ -73,46 +73,24 @@ pnpm turbo lint # Biome check (fails CI on warnings) pnpm turbo build # Production build ``` -Product/acceptance testing is direct `agent-browser --auto-connect --session cheatcode-debug` -UI interaction only: click/fill/type through real flows, capture screenshots, -inspect console/network output, and read app logs. Do not add or run scripted -test harnesses for product flows, browser automation, prompt submission, auth, -accessibility, load, or final E2E. Package `test` scripts are intentionally not -part of the V2 command surface, and source-level `*.test.ts` files are -intentionally absent. Do not generate temporary validation scripts either; -operate the UI directly and check logs, and remove any throwaway product QA -script that appears in the V2 tree. Operational scripts may exist only for -build, migration, and local-stack configuration; they are not -product tests and must not simulate UI/user flows. Do not create temporary -testing scripts in `scripts/`, package folders, `/tmp`, or any out-of-tree -location. - -May 27, 2026 user override: never use scripts for product testing. Product QA -means direct `agent-browser` UI operation, screenshots, console/network -inspection, and running app-log review only. Delete any future V2 product-flow -validator, prompt runner, browser wrapper, or throwaway QA helper on sight -instead of running it. The removed V1 tree must not be restored, copied back, -or used as a source of product-test scripts. - -May 28, 2026 hardening: do not wrap product QA in `pnpm`, `tsx`, shell loops, -`/tmp` helpers, generated files, browser-driver wrappers, package aliases, or -any scripted flow. Every product UI action, screenshot, console read, -network/resource inspection, and app-log inspection must be issued directly in -the transcript. Typecheck/lint/build are code-health gates only; they are not -product QA. - -May 28, 2026 direct override: delete any product-flow test script, temporary -helper, command-loop runner, browser wrapper, prompt driver, curl flow, or -package alias when discovered. The active V2 tree should contain no product-test -scripts. The remaining `scripts/` files are operational only and must not click -the UI, submit prompts, drive auth, gather acceptance evidence, or replace -direct `agent-browser` operation. - -May 28, 2026 latest user directive: code the all-weeks V2 surface first, then -run final product QA only through direct `agent-browser --auto-connect --session -cheatcode-debug` UI actions and direct console/network/app-log inspection. Do -not write, run, or keep scripts to submit prompts, click UI, drive auth, wrap -`agent-browser`, run curl flows, or gather acceptance evidence. +Product/acceptance testing uses only direct +`agent-browser --auto-connect --session cheatcode-debug` commands issued in the +transcript to click, fill, and type through real flows, capture screenshots, +inspect console/network/resources, and read running app logs. Never add, run, +write, or keep scripts for product testing, browser automation, prompt +submission, auth, accessibility, load, or final E2E, including `pnpm`/`tsx` +wrappers, shell or command loops, `/tmp` or generated helpers, browser-driver +wrappers, package aliases, prompt drivers, curl flows, validators, and +throwaway QA helpers in `scripts/`, package folders, or any out-of-tree +location; delete them on sight instead of running them. Package `test` scripts +and source-level `*.test.ts` files remain absent. Operational scripts may exist +only for build, migration, and local-stack configuration and must not click the +UI, submit prompts, drive auth, gather acceptance evidence, or replace direct +browser operation. Typecheck, lint, and build are code-health gates only, not +product QA. Code the all-weeks V2 surface first, then perform final QA through +the direct browser commands and direct console/network/app-log inspection. The +removed V1 tree must not be restored, copied back, used as a testing surface, or +used as a source of product-test scripts. ## Run locally @@ -121,47 +99,8 @@ pnpm dev # Compose: Next + chained Workers agains pnpm dev:down # Stop the local Compose stack ``` -Required local env vars in `.env.local` (template in `.env.example`): - -``` -# Production Supabase session-pooler URLs for the three isolated Worker roles. -# Administrative migration credentials never belong in .env.local. -SUPABASE_GATEWAY_DATABASE_URL= -SUPABASE_AGENT_DATABASE_URL= -SUPABASE_WEBHOOKS_DATABASE_URL= - -# Per-Worker signed tenant context (three distinct secrets, each at least 32 bytes) -DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY= -DATABASE_CONTEXT_SIGNING_SECRET_AGENT= -DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS= - -# Daytona -DAYTONA_API_KEY= -DAYTONA_API_URL=https://app.daytona.io/api -DAYTONA_TARGET=us -DAYTONA_SANDBOX_SNAPSHOT= -DAYTONA_WORKSPACE_VOLUME=cheatcode-workspaces-development -PREVIEW_TOKEN_SECRET= - -# Clerk development instance only -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= - -# Polar -POLAR_ACCESS_TOKEN= -POLAR_WEBHOOK_SECRET= - -# Composio -COMPOSIO_API_KEY= -COMPOSIO_AUTH_CONFIGS={"github":"ac_...","gmail":"ac_...","slack":"ac_...","notion":"ac_...","linear":"ac_..."} -COMPOSIO_WEBHOOK_SECRET= - -# Signed output capability -OUTPUT_DOWNLOAD_SIGNING_SECRET= - -# Browser-visible local routing configuration (not secrets) -NEXT_PUBLIC_GATEWAY_URL=http://127.0.0.1:8787 -``` +Copy the complete `.env.local` template from `.env.example`; +`scripts/dev.ts` validates it before local startup. Never commit `.env.local`. It is the sole laptop application credential file; its database URLs contain only the three least-privilege production runtime diff --git a/CLAUDE.md b/CLAUDE.md index e251136d..5dc77a0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,11 +37,9 @@ apps/ webhooks-worker/ Clerk, Polar, Composio webhooks + internal ops workflows packages/ - agent-core/ Mastra agent, workflows, tools, and runtime contexts + agent-core/ Mastra agent, workflows, contexts, and data/document/media tools tools-code/ Sandbox shell/file/git/runCode tools tools-browser/ Stagehand LOCAL browser automation - tools-docs/ pptxgenjs, docx, exceljs, @react-pdf/renderer - tools-data/ Arquero CSV analysis + deterministic SVG charts tools-research/ Exa + Firecrawl db/ Drizzle schema (per-domain) + queries + migrations byok/ Vault-backed BYOK with provider validation @@ -54,9 +52,9 @@ packages/ ui/ shared UI primitives, icon barrel, AI response renderer tsconfig/ Shared base/nextjs/worker/library configs -skills/ 8 curated Anthropic SKILL.md skills -infra/ Wrangler configs, Supabase migrations, Daytona sandbox Dockerfile/snapshot -scripts/ Operational helpers only: build skills, local startup, deploy orchestration, migrations, audit archive +skills/ 19 curated skills; _shared/office is vendored and not bundled +infra/ Container images and Dockerfiles for local development and Daytona sandboxes +scripts/ Operational helpers: build-skills, dev, dev-worker-config, jsonc, migrate, migration-drizzle, database-operation-safety ``` ## Critical conventions (non-negotiable) @@ -89,42 +87,29 @@ pnpm --filter @cheatcode/db db:generate # Create new migration ``` Pre-commit (Lefthook) runs Biome on staged + typecheck on changed packages. Must stay <5s. -Product/acceptance testing is direct `agent-browser --auto-connect --session cheatcode-debug` -UI operation plus console/network/app-log review. Do not add or run scripted -browser/product-flow test harnesses; package `test` scripts and source-level -`*.test.ts` files are intentionally absent from the V2 command surface. Do not generate temporary validation scripts either; operate -the UI directly and check logs, and remove any throwaway product QA script that -appears in the V2 tree. Operational scripts may exist only for build, -migration, secret sync, Docker cleanup, and guarded deploy orchestration; they are not -product tests and must not simulate UI/user flows. Do not create temporary -testing scripts in `scripts/`, package folders, `/tmp`, or any out-of-tree -location. Delete future V2 product validators instead of running them. The +Product/acceptance testing uses only direct +`agent-browser --auto-connect --session cheatcode-debug` commands issued in the +transcript to click, fill, and type through real flows, capture screenshots, +inspect console/network/resources, and read running app logs. Never add, run, +write, or keep scripts for browser or product-flow testing, including +`pnpm`/`tsx` wrappers, shell or command loops, `/tmp` or generated helpers, +browser-driver wrappers, package aliases, prompt drivers, curl flows, +validators, and throwaway QA helpers in `scripts/`, package folders, or any +out-of-tree location; delete them on sight instead of running them. Package +`test` scripts and source-level `*.test.ts` files remain absent. Operational +scripts may exist only for build, migration, secret sync, Docker cleanup, and +guarded deploy orchestration and must not click the UI, submit prompts, drive +auth, gather acceptance evidence, simulate UI/user flows, or replace direct +browser operation. Typecheck, lint, and build are code-health gates only, not +product QA. Code the all-weeks V2 surface first, then perform final QA through +the direct browser commands and direct console/network/app-log inspection. The removed V1 tree must not be restored or copied back as a testing surface. -May 28, 2026 hardening: do not wrap product QA in `pnpm`, `tsx`, shell loops, -`/tmp` helpers, generated files, browser-driver wrappers, package aliases, or -any scripted flow. Each UI action, screenshot, console read, network/resource -inspection, and app-log inspection must be issued directly in the transcript. -Typecheck/lint/build remain code-health gates only. - -May 28, 2026 direct override: delete any product-flow test script, temporary -helper, command-loop runner, browser wrapper, prompt driver, curl flow, or -package alias when discovered. The active V2 tree should contain no product-test -scripts. The remaining `scripts/` files are operational only and must not click -the UI, submit prompts, drive auth, gather acceptance evidence, or replace -direct `agent-browser` operation. - -May 28, 2026 latest user directive: code the all-weeks V2 surface first, then -run final product QA only through direct `agent-browser --auto-connect --session -cheatcode-debug` UI actions and direct console/network/app-log inspection. Do -not write, run, or keep scripts to submit prompts, click UI, drive auth, wrap -`agent-browser`, run curl flows, or gather acceptance evidence. - ## Where things live | Need | File | |---|---| -| Add a new tool | `packages/tools-/src/.ts` | +| Add a new tool | `packages/agent-core/src/tools//.ts` for data/docs/media; `packages/tools-/src/.ts` for browser/code/research | | Add a new agent | `packages/agent-core/src/mastra/agents/.ts` | | Add a new workflow | `packages/agent-core/src/mastra/workflows/.ts` | | Add a new skill | `skills//SKILL.md` (+ optional `references/` / `assets/`) | @@ -136,7 +121,13 @@ not write, run, or keep scripts to submit prompts, click UI, drive auth, wrap Curated skills are bundled at build time into `packages/skills/src/generated.ts` (Workers have no filesystem at runtime). Anthropic SKILL.md format. V2 has no bundled skill scripts, no `evals/evals.json`, no local skill-eval runner, and no `skill_run_script` tool. -The source-of-truth catalog is the set of skill folders under `skills/`; do not duplicate a manually maintained name list here. External skill registry exports, skills.sh links, public publishing scripts, and launch-prep copy are outside V2 unless the user explicitly re-expands the plan. +The source-of-truth catalog is the set of non-underscore skill folders under +`skills/`; do not duplicate a manually maintained name list here. +`skills/_shared/office/` is the shared vendored tree materialized by the sandbox +Dockerfile, and `scripts/build-skills.ts` skips `_`-prefixed directories. +External skill registry exports, skills.sh links, public publishing scripts, +and launch-prep copy are outside V2 unless the user explicitly re-expands the +plan. The bundler contract lives in `scripts/build-skills.ts` and `packages/skills`. diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index ba209ff7..795a9001 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -194,9 +194,6 @@ Mastra tool-call chunks also emit `step_started`, `step_completed`, live stream. If the last stream subscriber disconnects while a run is still running, AgentRun emits `run_abandoned` for the watchdog/funnel trail. -AgentRun also emits `data-plan` and `data-task-status` UI chunks so the web app can -render task progress without polling a separate status endpoint. - Project deletion first fences project/thread mutations, refuses an active run, records a durable cleanup request, then removes that project's workspace folder. The database marks cleanup complete only after the Agent service succeeds, so a repeated DELETE retries a diff --git a/apps/agent-worker/package.json b/apps/agent-worker/package.json index e035b2f3..be80adc5 100644 --- a/apps/agent-worker/package.json +++ b/apps/agent-worker/package.json @@ -7,7 +7,6 @@ "types": "./dist/index.d.ts", "scripts": { "build": "wrangler deploy --dry-run", - "deploy": "wrangler deploy", "lint": "biome check .", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/agent-worker/src/agent-api-run-routes.ts b/apps/agent-worker/src/agent-api-run-routes.ts index 7b10ac96..04671efd 100644 --- a/apps/agent-worker/src/agent-api-run-routes.ts +++ b/apps/agent-worker/src/agent-api-run-routes.ts @@ -27,6 +27,7 @@ import { type AgentRunAdmissionOutcome, activeRunForThreadRoute, agentRunForRunId, + callAgentRun, fetchAgentRun, reconcileAgentRunAdmission, runEntitlementPolicy, @@ -63,7 +64,6 @@ type RejectedRunResult = Exclude< export function registerAgentRunHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { app.post("/v1/threads/:threadId/runs", createRun); app.get("/v1/threads/:threadId/runs/stream", streamActiveRun); - app.get("/v1/threads/:threadId/runs/status", activeRunStatus); app.post("/v1/runs/:runId/cancel", cancelRun); app.get("/v1/threads/:threadId/browser-takeover", browserTakeoverStatus); app.post("/v1/threads/:threadId/browser-takeover/start", startBrowserTakeover); @@ -276,26 +276,11 @@ async function streamActiveRun(c: AgentContext): Promise { ); } -async function activeRunStatus(c: AgentContext): Promise { - const userId = readGatewayUserId(c.req.raw.headers); - const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); - const run = await activeRunForThreadRoute(c.env, userId, threadId); - if (!run) { - return new Response(null, { status: 204 }); - } - return fetchAgentRun(agentRunForRunId(c.env, run.runId), "https://agent-run.internal/status", { - headers: { "X-Cheatcode-User-Id": userId }, - }); -} - async function cancelRun(c: AgentContext): Promise { const userId = readGatewayUserId(c.req.raw.headers); const runId = parseRunRouteParam(c.req.param("runId") ?? ""); const run = await runForRoute(c.env, userId, runId); - return fetchAgentRun(agentRunForRunId(c.env, run.runId), "https://agent-run.internal/cancel", { - headers: { "X-Cheatcode-User-Id": userId }, - method: "POST", - }); + return callAgentRun(agentRunForRunId(c.env, run.runId).cancel(userId)); } async function browserTakeoverStatus(c: AgentContext): Promise { @@ -304,11 +289,7 @@ async function browserTakeoverStatus(c: AgentContext): Promise { if (!run) { return Response.json(BrowserTakeoverStatusSchema.parse({ status: "inactive" })); } - return fetchAgentRun( - agentRunForRunId(c.env, run.runId), - "https://agent-run.internal/browser-takeover", - { headers: { "X-Cheatcode-User-Id": userId } }, - ); + return callAgentRun(agentRunForRunId(c.env, run.runId).browserTakeoverStatus(userId)); } async function startBrowserTakeover(c: AgentContext): Promise { @@ -320,11 +301,7 @@ async function startBrowserTakeover(c: AgentContext): Promise { retriable: false, }); } - return fetchAgentRun( - agentRunForRunId(c.env, run.runId), - "https://agent-run.internal/browser-takeover/start", - { headers: { "X-Cheatcode-User-Id": userId }, method: "POST" }, - ); + return callAgentRun(agentRunForRunId(c.env, run.runId).browserTakeoverStart(userId)); } async function resumeBrowserTakeover(c: AgentContext): Promise { @@ -336,17 +313,8 @@ async function resumeBrowserTakeover(c: AgentContext): Promise { if (!run) { return Response.json(BrowserTakeoverResumeResultSchema.parse({ ok: true, status: "inactive" })); } - return fetchAgentRun( - agentRunForRunId(c.env, run.runId), - "https://agent-run.internal/browser-takeover/resume", - { - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json", - "X-Cheatcode-User-Id": userId, - }, - method: "POST", - }, + return callAgentRun( + agentRunForRunId(c.env, run.runId).browserTakeoverResume(userId, body.takeoverId), ); } diff --git a/apps/agent-worker/src/agent-api-system-routes.ts b/apps/agent-worker/src/agent-api-system-routes.ts index d106f972..3284a8c4 100644 --- a/apps/agent-worker/src/agent-api-system-routes.ts +++ b/apps/agent-worker/src/agent-api-system-routes.ts @@ -21,7 +21,7 @@ import { z } from "zod"; import type { AgentEnv } from "./agent-env"; import { agentRunForRunId, - fetchAgentRun, + callAgentRun, sandboxForUser, sandboxStubForUser, } from "./agent-routing"; @@ -99,11 +99,7 @@ async function deleteRunStates(env: AgentEnv, userId: string, runIds: string[]): if (!runId) { continue; } - const response = await fetchAgentRun( - agentRunForRunId(env, runId), - "https://agent-run.internal/delete-all", - { headers: { "X-Cheatcode-User-Id": userId }, method: "POST" }, - ); + const response = await callAgentRun(agentRunForRunId(env, runId).deleteAll(userId)); if (!response.ok) { const status = response.status; await response.body?.cancel().catch(() => undefined); diff --git a/apps/agent-worker/src/agent-env.ts b/apps/agent-worker/src/agent-env.ts index 1d44cabf..59fef149 100644 --- a/apps/agent-worker/src/agent-env.ts +++ b/apps/agent-worker/src/agent-env.ts @@ -3,6 +3,7 @@ import type { AnalyticsBindings } from "@cheatcode/observability"; import type { AgentRun } from "./durable-objects/agent-run"; import type { AgentRunWorkflowPayload } from "./durable-objects/agent-run-workflow-protocol"; import type { ProjectSandbox } from "./durable-objects/project-sandbox"; +import type { QuotaTrackerNamespace } from "./quota-tracker-binding"; export interface AgentEnv extends AnalyticsBindings { AGENT_RUN: DurableObjectNamespace; @@ -24,7 +25,7 @@ export interface AgentEnv extends AnalyticsBindings { PREVIEW_TOKEN_SECRET: WorkerSecret; PREVIEW_HOSTNAME: string; PROJECT_SANDBOX: DurableObjectNamespace; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: QuotaTrackerNamespace; R2_AUDIT: R2Bucket; R2_OUTPUTS: R2Bucket; SANDBOX_STATE?: KVNamespace; diff --git a/apps/agent-worker/src/agent-routing.ts b/apps/agent-worker/src/agent-routing.ts index ab7bfb4e..39099d14 100644 --- a/apps/agent-worker/src/agent-routing.ts +++ b/apps/agent-worker/src/agent-routing.ts @@ -15,12 +15,7 @@ import { type RunPersonalization, withUserContext, } from "@cheatcode/db"; -import { - APIError, - createLogger, - emitUserEvent, - readBoundedResponseJson, -} from "@cheatcode/observability"; +import { APIError, createLogger, emitUserEvent } from "@cheatcode/observability"; import { AgentRunId, type CreateRun, @@ -29,17 +24,12 @@ import { ThreadId, UserId, } from "@cheatcode/types"; -import { - QUOTA_FEATURES, - QUOTA_TRACKER_MAX_RESPONSE_BYTES, - QuotaPeekRequestSchema, - QuotaSetLimitRequestSchema, - QuotaSetLimitResponseSchema, - QuotaUsageResponseSchema, -} from "@cheatcode/types/quota"; +import { QUOTA_FEATURES } from "@cheatcode/types/quota"; import type { AgentEnv } from "./agent-env"; import type { AgentRun } from "./durable-objects/agent-run"; +import { type StartRunInput, StartRunInputSchema } from "./durable-objects/agent-run-schemas"; import type { ProjectSandbox } from "./durable-objects/project-sandbox"; +import type { QuotaTrackerStub } from "./quota-tracker-binding"; import { extractRunMessageText } from "./run-request"; import { userSandboxName } from "./tenancy"; @@ -195,7 +185,7 @@ export async function startAgentRun( const { body, modelExplicit, personalization, run, sandboxName, userId } = input; const messageText = extractRunMessageText(body); const stub = agentRunForRunId(env, run.runId); - const startBody = JSON.stringify({ + const startInput = StartRunInputSchema.parse({ isFirstRun: Boolean(run.isFirstRun), ...(personalization.agentDisplayName ? { agentDisplayName: personalization.agentDisplayName } @@ -215,7 +205,7 @@ export async function startAgentRun( threadId: run.threadId, userId, }); - const outcome = await attemptAgentRunStart(stub, userId, startBody); + const outcome = await attemptAgentRunStart(stub, userId, startInput); if (outcome.type === "confirmed") { emitRunStartEvents(env, { messageText, response: outcome.response, run, userId }); } @@ -234,17 +224,17 @@ export async function reconcileAgentRunAdmission( async function attemptAgentRunStart( stub: DurableObjectStub, userId: string, - startBody: string, + startInput: StartRunInput, ): Promise { try { - const first = await fetchAgentRunStart(stub, startBody); + const first = await stub.start(startInput); if (first.ok) { return { response: first, type: "confirmed" }; } await discardResponse(first); } catch { try { - const retry = await fetchAgentRunStart(stub, startBody); + const retry = await stub.start(startInput); if (retry.ok) { return { response: retry, type: "confirmed" }; } @@ -257,19 +247,13 @@ async function attemptAgentRunStart( return probeAgentRunAdmission(stub, userId); } -function fetchAgentRunStart(stub: DurableObjectStub, body: string): Promise { - return stub.fetch("https://agent-run.internal/start", { body, method: "POST" }); -} - async function probeAgentRunAdmission( stub: DurableObjectStub, userId: string, ): Promise { let statusResponse: Response; try { - statusResponse = await stub.fetch("https://agent-run.internal/status", { - headers: { "X-Cheatcode-User-Id": userId }, - }); + statusResponse = await stub.status(userId); } catch { return { type: "ambiguous" }; } @@ -366,49 +350,20 @@ async function enforceSandboxHoursGate( } async function syncSandboxHoursLimit( - stub: DurableObjectStub, + stub: QuotaTrackerStub, allowanceHours: number, entitlementVersion: number, ): Promise { try { - const body = QuotaSetLimitRequestSchema.parse({ - entitlementVersion, - feature: QUOTA_FEATURES.sandboxHours, - limit: allowanceHours, - }); - const response = await stub.fetch("https://quota.internal/set-limit", { - body: JSON.stringify(body), - method: "POST", - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new Error(`QuotaTracker set-limit failed with HTTP ${response.status}`); - } - QuotaSetLimitResponseSchema.parse( - await readBoundedResponseJson(response, QUOTA_TRACKER_MAX_RESPONSE_BYTES, "Quota set-limit"), - ); + await stub.setLimit(QUOTA_FEATURES.sandboxHours, allowanceHours, entitlementVersion); } catch { throw quotaTrackerUnavailableError(); } } -async function peekSandboxHoursUsed(stub: DurableObjectStub, periodEnd: Date): Promise { +async function peekSandboxHoursUsed(stub: QuotaTrackerStub, periodEnd: Date): Promise { try { - const body = QuotaPeekRequestSchema.parse({ - feature: QUOTA_FEATURES.sandboxHours, - periodEnd: periodEnd.toISOString(), - }); - const response = await stub.fetch("https://quota.internal/peek", { - body: JSON.stringify(body), - method: "POST", - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new Error(`QuotaTracker peek failed with HTTP ${response.status}`); - } - return QuotaUsageResponseSchema.parse( - await readBoundedResponseJson(response, QUOTA_TRACKER_MAX_RESPONSE_BYTES, "Quota tracker"), - ).used; + return (await stub.peek(QUOTA_FEATURES.sandboxHours, periodEnd)).used; } catch { throw quotaTrackerUnavailableError(); } @@ -519,6 +474,14 @@ export async function fetchAgentRun( } } +export async function callAgentRun(operation: Promise): Promise { + try { + return await operation; + } catch (error) { + throw agentRunUnavailableError(error); + } +} + function agentRunUnavailableError(error: unknown): APIError { const isFreeTierDuration = isDurableObjectFreeTierDurationError(error); return new APIError(503, "unavailable_maintenance", "Agent run service is unavailable", { diff --git a/apps/agent-worker/src/durable-objects/agent-run-env.ts b/apps/agent-worker/src/durable-objects/agent-run-env.ts index 7a575ada..596dde67 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-env.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-env.ts @@ -1,5 +1,6 @@ import type { WorkerSecret } from "@cheatcode/env"; import type { AnalyticsBindings } from "@cheatcode/observability"; +import type { QuotaTrackerNamespace } from "../quota-tracker-binding"; import type { AgentRunWorkflowPayload } from "./agent-run-workflow-protocol"; import type { ProjectSandbox } from "./project-sandbox"; @@ -14,6 +15,6 @@ export interface AgentRunEnv extends AnalyticsBindings { OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecret; PREVIEW_HOSTNAME: string; PROJECT_SANDBOX: DurableObjectNamespace; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: QuotaTrackerNamespace; R2_OUTPUTS: R2Bucket; } diff --git a/apps/agent-worker/src/durable-objects/agent-run-http.ts b/apps/agent-worker/src/durable-objects/agent-run-http.ts deleted file mode 100644 index fb7f7da3..00000000 --- a/apps/agent-worker/src/durable-objects/agent-run-http.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { APIError, readJsonRequest } from "@cheatcode/observability"; -import { BrowserTakeoverResumeSchema } from "@cheatcode/types"; -import { type StartRunInput, StartRunInputSchema } from "./agent-run-schemas"; -import { missingInternalUserResponse } from "./agent-run-utils"; -import { - type AgentRunWorkflowCallbackInput, - AgentRunWorkflowCallbackInputSchema, - type AgentRunWorkflowFailureInput, - AgentRunWorkflowFailureInputSchema, -} from "./agent-run-workflow-protocol"; -import { parseLastSeqParam } from "./run-state"; - -const INTERNAL_USER_HEADER = "X-Cheatcode-User-Id"; -const MAX_START_RUN_REQUEST_BYTES = 128 * 1024; -const MAX_WORKFLOW_FAILURE_REQUEST_BYTES = 4 * 1024; -const MAX_WORKFLOW_EXECUTE_REQUEST_BYTES = 256 * 1024; -const MAX_BROWSER_TAKEOVER_REQUEST_BYTES = 4 * 1024; - -type ResponseResult = Promise | Response; - -export interface AgentRunHttpHandlers { - cancel: (userId: string) => ResponseResult; - browserTakeoverResume: (userId: string, takeoverId: string) => ResponseResult; - browserTakeoverStart: (userId: string) => ResponseResult; - browserTakeoverStatus: (userId: string) => ResponseResult; - deleteAll: (userId: string) => ResponseResult; - executeWorkflow: (input: AgentRunWorkflowCallbackInput) => ResponseResult; - failWorkflow: (input: AgentRunWorkflowFailureInput) => ResponseResult; - rolloverWorkflow: (input: AgentRunWorkflowCallbackInput) => ResponseResult; - resume: (userId: string, lastSeq: number) => ResponseResult; - start: (input: StartRunInput) => ResponseResult; - status: (userId: string) => ResponseResult; -} - -/** HTTP adapter for the run-keyed Durable Object. */ -export async function handleAgentRunRequest( - request: Request, - handlers: AgentRunHttpHandlers, -): Promise { - const url = new URL(request.url); - if (request.method === "GET") { - return handleGet(request, url, handlers); - } - if (request.method === "POST") { - return handlePost(request, url.pathname, handlers); - } - return notFound(); -} - -async function handleGet( - request: Request, - url: URL, - handlers: AgentRunHttpHandlers, -): Promise { - if (url.pathname === "/status") { - const userId = internalUser(request); - if (!userId) { - return missingInternalUserResponse("status"); - } - return handlers.status(userId); - } - if (url.pathname === "/browser-takeover") { - const userId = internalUser(request); - if (!userId) return missingInternalUserResponse("browser takeover"); - return handlers.browserTakeoverStatus(userId); - } - if (url.pathname === "/stream") { - return handleStream(request, url, handlers); - } - return notFound(); -} - -async function handleStream( - request: Request, - url: URL, - handlers: AgentRunHttpHandlers, -): Promise { - const lastSeq = parseLastSeqParam(url.searchParams.get("lastSeq")); - if (lastSeq === null) { - return invalidResumeCursorResponse(); - } - const userId = internalUser(request); - if (!userId) { - return missingInternalUserResponse("streams"); - } - return handlers.resume(userId, lastSeq); -} - -async function handlePost( - request: Request, - pathname: string, - handlers: AgentRunHttpHandlers, -): Promise { - if (pathname === "/start") { - const input = StartRunInputSchema.parse( - await readJsonRequest(request, MAX_START_RUN_REQUEST_BYTES, "Agent run start request"), - ); - return handlers.start(input); - } - if (pathname === "/workflow/execute") { - return handleWorkflowExecute(request, handlers); - } - if (pathname === "/workflow/failed") { - return handleWorkflowFailure(request, handlers); - } - if (pathname === "/workflow/rollover") { - return handleWorkflowRollover(request, handlers); - } - if (pathname === "/browser-takeover/start") { - const userId = internalUser(request); - if (!userId) return missingInternalUserResponse("browser takeover"); - return handlers.browserTakeoverStart(userId); - } - if (pathname === "/browser-takeover/resume") { - const userId = internalUser(request); - if (!userId) return missingInternalUserResponse("browser takeover"); - const body = BrowserTakeoverResumeSchema.parse( - await readJsonRequest( - request, - MAX_BROWSER_TAKEOVER_REQUEST_BYTES, - "Browser takeover resume request", - ), - ); - return handlers.browserTakeoverResume(userId, body.takeoverId); - } - if (pathname !== "/cancel" && pathname !== "/delete-all") { - return notFound(); - } - const userId = internalUser(request); - if (!userId) { - return missingInternalUserResponse(postOperationName(pathname)); - } - if (pathname === "/cancel") { - return handlers.cancel(userId); - } - if (pathname === "/delete-all") { - return handlers.deleteAll(userId); - } - return notFound(); -} - -async function handleWorkflowExecute( - request: Request, - handlers: AgentRunHttpHandlers, -): Promise { - const input = AgentRunWorkflowCallbackInputSchema.parse( - await readJsonRequest( - request, - MAX_WORKFLOW_EXECUTE_REQUEST_BYTES, - "AgentRun Workflow execution request", - ), - ); - return handlers.executeWorkflow(input); -} - -async function handleWorkflowFailure( - request: Request, - handlers: AgentRunHttpHandlers, -): Promise { - const input = AgentRunWorkflowFailureInputSchema.parse( - await readJsonRequest( - request, - MAX_WORKFLOW_FAILURE_REQUEST_BYTES, - "AgentRun Workflow failure request", - ), - ); - return handlers.failWorkflow(input); -} - -async function handleWorkflowRollover( - request: Request, - handlers: AgentRunHttpHandlers, -): Promise { - const input = AgentRunWorkflowCallbackInputSchema.parse( - await readJsonRequest( - request, - MAX_WORKFLOW_EXECUTE_REQUEST_BYTES, - "AgentRun Workflow rollover request", - ), - ); - return handlers.rolloverWorkflow(input); -} - -function postOperationName(pathname: string): "cancel" | "delete-all" { - if (pathname === "/cancel") return "cancel"; - return "delete-all"; -} - -function invalidResumeCursorResponse(): Response { - return new APIError(400, "invalid_query_param", "Invalid resume cursor", { - hint: "Pass lastSeq as a non-negative integer.", - retriable: false, - }).toResponse(`req_${crypto.randomUUID().replaceAll("-", "")}`); -} - -function internalUser(request: Request): string | null { - return request.headers.get(INTERNAL_USER_HEADER); -} - -function notFound(): Response { - return new Response("Not found", { status: 404 }); -} diff --git a/apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts b/apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts index 776b3dee..5634900c 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts @@ -5,7 +5,6 @@ import type { AgentRunEnv } from "./agent-run-env"; import { toAgentRunStreamError } from "./agent-run-errors"; import { persistOrQueueAssistantMessage } from "./agent-run-message-persistence"; import type { AgentRunOutput } from "./agent-run-output"; -import { runPlanChunk, runTaskStatusChunk } from "./agent-run-progress"; import type { StartRunInput } from "./agent-run-schemas"; import type { PersistableRunStatus } from "./agent-run-status-persistence"; import type { ProjectSandbox } from "./project-sandbox"; @@ -86,7 +85,6 @@ async function executeActiveRun(execution: RunExecution): Promise { const { deps, input } = execution; await deps.persistRunStatus(input, "running"); await deps.append({ messageId: input.runId, type: "start" }); - await deps.append(runPlanChunk()); await deps.append({ type: "data-sandbox-status", data: { v: 1, status: "starting" } }); deps.setRunStage("Preparing project sandbox."); await openRunLease(execution); @@ -94,8 +92,6 @@ async function executeActiveRun(execution: RunExecution): Promise { return; } await restoreRunProjectFiles(execution); - await deps.append(runTaskStatusChunk("prepare-sandbox", "completed")); - await deps.append(runTaskStatusChunk("run-agent", "running")); const path = await deps.executeRunPath( input, execution.sandbox, @@ -128,10 +124,7 @@ async function renewRunLease(execution: RunExecution): Promise { async function completeRun(execution: RunExecution): Promise { const { deps, input } = execution; await deps.finalizeTerminal("completed", async () => { - await deps.append(runTaskStatusChunk("run-agent", "completed")); - await deps.append(runTaskStatusChunk("stream-results", "running")); await deps.append({ type: "data-sandbox-status", data: { v: 1, status: "ready" } }); - await deps.append(runTaskStatusChunk("stream-results", "completed")); await deps.output.appendClosingBackstop(); await deps.output.ensureAnswerSegmentEnded(); await deps.append({ type: "finish", finishReason: "stop" }); @@ -153,8 +146,6 @@ async function failRun(execution: RunExecution, error: unknown): Promise { retriable: streamError.retriable, }); await deps.append({ type: "data-sandbox-status", data: { v: 1, status: "failed" } }); - await deps.append(runTaskStatusChunk("run-agent", "failed", streamError.message)); - await deps.append(runTaskStatusChunk("stream-results", "failed", streamError.message)); await deps.append({ type: "data-error", data: { diff --git a/apps/agent-worker/src/durable-objects/agent-run-progress.ts b/apps/agent-worker/src/durable-objects/agent-run-progress.ts deleted file mode 100644 index adfa7ecf..00000000 --- a/apps/agent-worker/src/durable-objects/agent-run-progress.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { TaskStatus } from "@cheatcode/types"; -import type { UIMessageChunk } from "ai"; - -type AgentRunTaskId = "prepare-sandbox" | "run-agent" | "stream-results"; - -const RUN_TASKS: ReadonlyArray<{ id: AgentRunTaskId; title: string }> = [ - { id: "prepare-sandbox", title: "Prepare sandbox" }, - { id: "run-agent", title: "Run agent tools" }, - { id: "stream-results", title: "Stream workspace result" }, -]; - -export function runPlanChunk(): UIMessageChunk { - return { - type: "data-plan", - data: { - v: 1, - parallelGroups: [[0], [1], [2]], - tasks: RUN_TASKS.map((task, index) => ({ - id: task.id, - status: index === 0 ? "running" : "pending", - title: task.title, - })), - }, - }; -} - -export function runTaskStatusChunk( - taskId: AgentRunTaskId, - status: TaskStatus, - error?: string, -): UIMessageChunk { - return { - type: "data-task-status", - data: { - v: 1, - taskId, - status, - ...(error ? { error } : {}), - }, - }; -} diff --git a/apps/agent-worker/src/durable-objects/agent-run-skill-runtime.ts b/apps/agent-worker/src/durable-objects/agent-run-skill-runtime.ts index 3946752c..57203205 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-skill-runtime.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-skill-runtime.ts @@ -1,4 +1,4 @@ -import { mintSkillRuntimeCapability, type SkillRuntimeScope } from "@cheatcode/auth"; +import { mintSkillRuntimeCapability } from "@cheatcode/auth"; import { createDb, rotateSkillRuntimeCapabilities, @@ -6,7 +6,7 @@ import { withUserContext, } from "@cheatcode/db"; import type { SandboxLike } from "@cheatcode/sandbox-contracts"; -import { AgentRunId, UserId } from "@cheatcode/types"; +import { AgentRunId, type SkillRuntimeScope, UserId } from "@cheatcode/types"; import type { AgentRunEnv } from "./agent-run-env"; import type { StartRunInput } from "./agent-run-schemas"; diff --git a/apps/agent-worker/src/durable-objects/agent-run-workflow-protocol.ts b/apps/agent-worker/src/durable-objects/agent-run-workflow-protocol.ts index c84ee5f7..0db31484 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow-protocol.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow-protocol.ts @@ -57,22 +57,16 @@ export const AgentRunWorkflowPayloadSchema = z export type AgentRunWorkflowPayload = z.infer; -export const AgentRunWorkflowCallbackInputSchema = AgentRunWorkflowPayloadSchema.extend({ - workflowInstanceId: z.string().min(1).max(100), -}).strict(); - -export type AgentRunWorkflowCallbackInput = z.infer; - -export const AgentRunWorkflowFailureInputSchema = z - .object({ - generation: WorkflowGenerationSchema, - inputHash: Sha256HexSchema, - message: z.string().trim().min(1).max(500), - workflowInstanceId: z.string().min(1).max(100), - }) - .strict(); +export interface AgentRunWorkflowCallbackInput extends AgentRunWorkflowPayload { + workflowInstanceId: string; +} -export type AgentRunWorkflowFailureInput = z.infer; +export interface AgentRunWorkflowFailureInput { + generation: number; + inputHash: string; + message: string; + workflowInstanceId: string; +} export const AgentRunWorkflowEpochResultSchema = z .object({ diff --git a/apps/agent-worker/src/durable-objects/agent-run-workflow.ts b/apps/agent-worker/src/durable-objects/agent-run-workflow.ts index afddcd1f..3f0df859 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow.ts @@ -151,10 +151,7 @@ async function reserveAgentRunSuccessor( payload: AgentRunWorkflowPayload, ): Promise { const stub = env.AGENT_RUN.get(env.AGENT_RUN.idFromName(payload.input.runId)); - const response = await stub.fetch("https://agent-run.internal/workflow/rollover", { - body: JSON.stringify({ ...payload, workflowInstanceId }), - method: "POST", - }); + const response = await stub.rolloverWorkflow({ ...payload, workflowInstanceId }); if (!response.ok) { const detail = await readBoundedResponseText( response, @@ -196,10 +193,7 @@ async function executeEpoch( payload: AgentRunWorkflowPayload, ): Promise { const stub = env.AGENT_RUN.get(env.AGENT_RUN.idFromName(payload.input.runId)); - const response = await stub.fetch("https://agent-run.internal/workflow/execute", { - body: JSON.stringify({ ...payload, workflowInstanceId }), - method: "POST", - }); + const response = await stub.executeWorkflow({ ...payload, workflowInstanceId }); if (!response.ok) { const detail = await readBoundedResponseText( response, @@ -227,14 +221,11 @@ async function terminalizeOwnershipFailure( payload: AgentRunWorkflowPayload, ): Promise<{ ok: true }> { const stub = env.AGENT_RUN.get(env.AGENT_RUN.idFromName(payload.input.runId)); - const response = await stub.fetch("https://agent-run.internal/workflow/failed", { - body: JSON.stringify({ - inputHash: payload.inputHash, - generation: payload.generation, - message: "Durable AgentRun execution ownership failed.", - workflowInstanceId, - }), - method: "POST", + const response = await stub.failWorkflow({ + inputHash: payload.inputHash, + generation: payload.generation, + message: "Durable AgentRun execution ownership failed.", + workflowInstanceId, }); if (!response.ok) { await response.body?.cancel().catch(() => undefined); diff --git a/apps/agent-worker/src/durable-objects/agent-run.ts b/apps/agent-worker/src/durable-objects/agent-run.ts index 8e5a3900..92d546d8 100644 --- a/apps/agent-worker/src/durable-objects/agent-run.ts +++ b/apps/agent-worker/src/durable-objects/agent-run.ts @@ -14,7 +14,6 @@ import { storeAgentArtifact } from "./agent-run-artifacts"; import { AgentRunBrowserTakeover } from "./agent-run-browser-takeover"; import { emitMastraChunkTelemetry } from "./agent-run-chunk-telemetry"; import type { AgentRunEnv } from "./agent-run-env"; -import { handleAgentRunRequest } from "./agent-run-http"; import { executeAgentRunLifecycle } from "./agent-run-lifecycle"; import { pendingAssistantMessageRetryAt, @@ -55,10 +54,15 @@ import { upsertRunRow, } from "./agent-run-storage"; import type { StreamDriverDeps } from "./agent-run-stream-driver"; +import { missingInternalUserResponse } from "./agent-run-utils"; import { AgentRunWorkflowController } from "./agent-run-workflow-controller"; +import type { + AgentRunWorkflowCallbackInput, + AgentRunWorkflowFailureInput, +} from "./agent-run-workflow-protocol"; import { createRunWorkspaceResolver } from "./agent-run-workspace"; import { mastraChunkError, normalizeMastraStreamError } from "./mastra-stream-chunks"; -import { hasActiveRun } from "./run-state"; +import { hasActiveRun, parseLastSeqParam } from "./run-state"; import { type AgentRunSnapshotStatus, snapshotAgentRunStatus } from "./run-summary"; type ProjectSandboxStub = CodeRuntimeContext["sandbox"]; @@ -165,44 +169,109 @@ export class AgentRun extends DurableObject { } public override fetch(request: Request): Promise { - // FIFO admission makes /start settle before a later presence probe observes the object. - const response = this.requestAdmissionTail.then(() => { - const hasStorage = hasAgentRunStorage(this.ctx); - if (hasStorage) { + const url = new URL(request.url); + if (request.method !== "GET" || url.pathname !== "/stream") { + return Promise.resolve(new Response("Not found", { status: 404 })); + } + const lastSeq = parseLastSeqParam(url.searchParams.get("lastSeq")); + if (lastSeq === null) { + return Promise.resolve(invalidResumeCursorResponse()); + } + const userId = request.headers.get("X-Cheatcode-User-Id"); + if (!userId) { + return Promise.resolve(missingInternalUserResponse("streams")); + } + return this.enqueueRequest(() => + this.withStorage( + () => this.resume(userId, lastSeq), + () => new Response(null, { status: 204 }), + ), + ); + } + + public start(input: StartRunInput): Promise { + return this.enqueueRequest(() => { + if (!hasAgentRunStorage(this.ctx)) { + initializeAgentRunStorage(this.ctx); + } else { assertAgentRunStorage(this.ctx); } - return handleAgentRunRequest(request, { - browserTakeoverResume: (userId, takeoverId) => - hasStorage ? this.browserTakeover.resume(userId, takeoverId) : absentAgentRunOkResponse(), - browserTakeoverStart: (userId) => - hasStorage ? this.browserTakeover.start(userId) : absentAgentRunOkResponse(), - browserTakeoverStatus: (userId) => - hasStorage ? this.browserTakeover.status(userId) : absentAgentRunOkResponse(), - cancel: (userId) => (hasStorage ? this.cancel(userId) : absentAgentRunOkResponse()), - deleteAll: (userId) => - hasStorage ? this.deleteAllState(userId) : absentAgentRunOkResponse(), - executeWorkflow: (input) => - hasStorage - ? agentRunWorkflowResponse(() => this.workflow.executeEpoch(input)) - : absentAgentRunWorkflowResponse(), - failWorkflow: (input) => - hasStorage - ? agentRunWorkflowResponse(() => this.workflow.failWorkflow(input)) - : absentAgentRunOkResponse(), - rolloverWorkflow: (input) => - hasStorage - ? agentRunWorkflowResponse(() => this.workflow.reserveSuccessor(input)) - : absentAgentRunWorkflowResponse(), - resume: (userId, lastSeq) => - hasStorage ? this.resume(userId, lastSeq) : new Response(null, { status: 204 }), - start: (input) => { - if (!hasStorage) initializeAgentRunStorage(this.ctx); - return this.start(input); - }, - status: (userId) => - hasStorage ? this.status(userId) : new Response(null, { status: 204 }), - }); + return this.startInternal(input); }); + } + + public status(userId: string): Promise { + return this.enqueueRequest(() => + this.withStorage( + () => this.statusInternal(userId), + () => new Response(null, { status: 204 }), + ), + ); + } + + public cancel(userId: string): Promise { + return this.enqueueRequest(() => + this.withStorage(() => this.cancelInternal(userId), absentAgentRunOkResponse), + ); + } + + public deleteAll(userId: string): Promise { + return this.enqueueRequest(() => + this.withStorage(() => this.deleteAllState(userId), absentAgentRunOkResponse), + ); + } + + public browserTakeoverStatus(userId: string): Promise { + return this.enqueueRequest(() => + this.withStorage(() => this.browserTakeover.status(userId), absentAgentRunOkResponse), + ); + } + + public browserTakeoverStart(userId: string): Promise { + return this.enqueueRequest(() => + this.withStorage(() => this.browserTakeover.start(userId), absentAgentRunOkResponse), + ); + } + + public browserTakeoverResume(userId: string, takeoverId: string): Promise { + return this.enqueueRequest(() => + this.withStorage( + () => this.browserTakeover.resume(userId, takeoverId), + absentAgentRunOkResponse, + ), + ); + } + + public executeWorkflow(input: AgentRunWorkflowCallbackInput): Promise { + return this.enqueueRequest(() => + this.withStorage( + () => agentRunWorkflowResponse(() => this.workflow.executeEpoch(input)), + absentAgentRunWorkflowResponse, + ), + ); + } + + public failWorkflow(input: AgentRunWorkflowFailureInput): Promise { + return this.enqueueRequest(() => + this.withStorage( + () => agentRunWorkflowResponse(() => this.workflow.failWorkflow(input)), + absentAgentRunOkResponse, + ), + ); + } + + public rolloverWorkflow(input: AgentRunWorkflowCallbackInput): Promise { + return this.enqueueRequest(() => + this.withStorage( + () => agentRunWorkflowResponse(() => this.workflow.reserveSuccessor(input)), + absentAgentRunWorkflowResponse, + ), + ); + } + + private enqueueRequest(operation: () => Promise | Response): Promise { + // FIFO admission makes start settle before a later presence probe observes the object. + const response = this.requestAdmissionTail.then(operation); this.requestAdmissionTail = response.then( () => undefined, () => undefined, @@ -210,7 +279,18 @@ export class AgentRun extends DurableObject { return response; } - private async start(input: StartRunInput): Promise { + private withStorage( + operation: () => Promise | Response, + absent: () => Response, + ): Promise | Response { + if (!hasAgentRunStorage(this.ctx)) { + return absent(); + } + assertAgentRunStorage(this.ctx); + return operation(); + } + + private async startInternal(input: StartRunInput): Promise { if (this.deletionInProgress || isAgentRunDeleted(this.ctx)) { return deletedAgentRunResponse(); } @@ -288,7 +368,7 @@ export class AgentRun extends DurableObject { return stream ? createAgentStreamResponse({ stream }) : agentRunStreamCapacityResponse(); } - private async status(userId: string): Promise { + private async statusInternal(userId: string): Promise { const runId = getRunStateValue(this.ctx, "run_id"); if (!runId) { return new Response(null, { status: 204 }); @@ -337,7 +417,7 @@ export class AgentRun extends DurableObject { return Response.json({ ok: true }); } - private async cancel(userId: string): Promise { + private async cancelInternal(userId: string): Promise { const ownerUserId = this.getOwnerUserId(); if (ownerUserId !== userId) { return new APIError(403, "permission_denied", "Run ownership mismatch", { @@ -729,3 +809,10 @@ export class AgentRun extends DurableObject { await current; } } + +function invalidResumeCursorResponse(): Response { + return new APIError(400, "invalid_query_param", "Invalid resume cursor", { + hint: "Pass lastSeq as a non-negative integer.", + retriable: false, + }).toResponse(`req_${crypto.randomUUID().replaceAll("-", "")}`); +} diff --git a/apps/agent-worker/src/durable-objects/composio-provider.ts b/apps/agent-worker/src/durable-objects/composio-provider.ts index 57ae1830..ac5b59e1 100644 --- a/apps/agent-worker/src/durable-objects/composio-provider.ts +++ b/apps/agent-worker/src/durable-objects/composio-provider.ts @@ -12,23 +12,17 @@ import { withUserContext, } from "@cheatcode/db"; import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env"; -import { type createLogger, readBoundedResponseJson } from "@cheatcode/observability"; +import type { createLogger } from "@cheatcode/observability"; import { IntegrationNameSchema, UserId } from "@cheatcode/types"; -import { - QUOTA_FEATURES, - QUOTA_TRACKER_MAX_RESPONSE_BYTES, - QuotaSetLimitRequestSchema, - QuotaSetLimitResponseSchema, - QuotaTryConsumeRequestSchema, - QuotaTryConsumeResponseSchema, -} from "@cheatcode/types/quota"; +import { QUOTA_FEATURES } from "@cheatcode/types/quota"; +import type { QuotaTrackerNamespace } from "../quota-tracker-binding"; import { closeDatabaseBestEffort } from "./db-close"; interface ComposioProviderEnv { COMPOSIO_API_KEY?: WorkerSecret; DATABASE_CONTEXT_SIGNING_SECRET_AGENT: WorkerSecret; HYPERDRIVE: Hyperdrive; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: QuotaTrackerNamespace; } interface ComposioProviderInput { @@ -137,54 +131,14 @@ function quotaMeter( } async function consumeComposioQuota( - namespace: DurableObjectNamespace, + namespace: QuotaTrackerNamespace, userId: UserId, quota: ComposioQuotaConfig, eventId: string, ): Promise { const stub = namespace.get(namespace.idFromName(`quota:${userId}`)); - const limitBody = QuotaSetLimitRequestSchema.parse({ - entitlementVersion: quota.entitlementVersion, - feature: QUOTA_FEATURES.composioCalls, - limit: quota.limit, - }); - const limitResponse = await requireQuotaResponse( - stub.fetch("https://quota.internal/set-limit", { - body: JSON.stringify(limitBody), - method: "POST", - }), - ); - QuotaSetLimitResponseSchema.parse( - await readBoundedResponseJson( - limitResponse, - QUOTA_TRACKER_MAX_RESPONSE_BYTES, - "Quota set-limit", - ), - ); - const consumeBody = QuotaTryConsumeRequestSchema.parse({ - amount: 1, - eventId, - feature: QUOTA_FEATURES.composioCalls, - periodEnd: quota.periodEnd.toISOString(), - }); - const response = await requireQuotaResponse( - stub.fetch("https://quota.internal/try-consume", { - body: JSON.stringify(consumeBody), - method: "POST", - }), - ); - return QuotaTryConsumeResponseSchema.parse( - await readBoundedResponseJson(response, QUOTA_TRACKER_MAX_RESPONSE_BYTES, "Quota tracker"), - ); -} - -async function requireQuotaResponse(responsePromise: Promise): Promise { - const response = await responsePromise; - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new Error("Quota tracker rejected Composio call metering."); - } - return response; + await stub.setLimit(QUOTA_FEATURES.composioCalls, quota.limit, quota.entitlementVersion); + return stub.tryConsume(QUOTA_FEATURES.composioCalls, 1, quota.periodEnd, eventId); } async function closeDatabase( diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-content-support.ts b/apps/agent-worker/src/durable-objects/project-sandbox-content-support.ts index 7295abe1..e27bb330 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-content-support.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-content-support.ts @@ -1,15 +1,11 @@ import { APIError } from "@cheatcode/observability"; -import { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES, type SandboxFilePreview } from "@cheatcode/types"; +import { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES } from "@cheatcode/types"; import { shellQuote } from "./project-sandbox-process-support"; import { type ProjectSearchFilesInput, ProjectSearchFilesInputSchema, } from "./project-sandbox-runtime"; -// Base64 expands by one third; keep the encoded JSON response below the web -// client's 8 MiB file-response boundary and the Worker's isolate memory budget. -export const MAX_PREVIEW_BYTES = 5 * 1024 * 1024; -export const PREVIEW_DIR = "/workspace/.cheatcode-previews"; export const PROJECT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024; export const PROJECT_ARCHIVE_MAX_FILES = 25_000; export const WORKSPACE_DIR = "/workspace"; @@ -134,69 +130,6 @@ export function assertDeletableWorkspacePath(path: string): void { assertMutableWorkspacePath(path); } -export function lowercaseExtension(path: string): string { - const filename = basename(path).toLowerCase(); - const dot = filename.lastIndexOf("."); - return dot === -1 ? "" : filename.slice(dot); -} - -export function imageMimeType(extension: string): string | null { - const mimeTypes: Record = { - ".gif": "image/gif", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".png": "image/png", - ".svg": "image/svg+xml", - ".webp": "image/webp", - }; - return mimeTypes[extension] ?? null; -} - -const OFFICE_PREVIEW_EXTENSIONS = new Set([ - ".doc", - ".docx", - ".odp", - ".ods", - ".odt", - ".pot", - ".potx", - ".pps", - ".ppsx", - ".ppt", - ".pptx", - ".xls", - ".xlsx", -]); - -export function isOfficePreviewExtension(extension: string): boolean { - return OFFICE_PREVIEW_EXTENSIONS.has(extension); -} - -export function unsupportedPreview(path: string, error: string): SandboxFilePreview { - return { - content: null, - encoding: null, - error, - kind: "unsupported", - mimeType: null, - path, - previewPath: null, - }; -} - -export function conversionErrorMessage(output: string): string { - const trimmed = output.trim(); - if (!trimmed) { - return "Office preview conversion failed."; - } - return trimmed.length > 1_000 ? `${trimmed.slice(0, 997)}...` : trimmed; -} - -export function withoutExtension(filename: string): string { - const dot = filename.lastIndexOf("."); - return dot <= 0 ? filename : filename.slice(0, dot); -} - export function buildGrepCommand(input: ProjectSearchFilesInput): string { const parsed = ProjectSearchFilesInputSchema.parse(input); const flags = ["-rnI"]; @@ -235,11 +168,6 @@ export function dirname(path: string): string { return index <= 0 ? "/" : path.slice(0, index); } -export function basename(path: string): string { - const index = path.lastIndexOf("/"); - return index === -1 ? path : path.slice(index + 1); -} - export function encodeBase64(bytes: Uint8Array): string { const chunkSize = 24 * 1024; const encoded: string[] = []; diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts index 5d56292d..5596721d 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts @@ -7,7 +7,6 @@ import type { SandboxWriteFileResult, } from "@cheatcode/sandbox-contracts"; import { DaytonaApiError } from "@cheatcode/tools-code"; -import type { SandboxFilePreview } from "@cheatcode/types"; import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host"; import { CODE_SERVER_DISPLAY_DIR, @@ -22,25 +21,16 @@ import { import { assertDeletableWorkspacePath, assertMutableWorkspacePath, - basename, buildGrepCommand, - conversionErrorMessage, decodeBase64, dirname, encodeBase64, - imageMimeType, - isOfficePreviewExtension, - lowercaseExtension, - MAX_PREVIEW_BYTES, - PREVIEW_DIR, PROJECT_ARCHIVE_MAX_BYTES, PROJECT_ARCHIVE_MAX_FILES, PROJECT_ARCHIVE_MAX_OUTPUT_BYTES, PROJECT_ARCHIVE_SCRIPT, parseGrepOutput, - unsupportedPreview, WORKSPACE_DIR, - withoutExtension, } from "./project-sandbox-content-support"; import { listSandboxFiles } from "./project-sandbox-files"; import { buildPreviewUrl, signedUrlToExpo } from "./project-sandbox-preview"; @@ -68,8 +58,6 @@ import { ProjectDeleteFileInputSchema, type ProjectListFilesInput, ProjectListFilesInputSchema, - type ProjectPreviewFileInput, - ProjectPreviewFileInputSchema, type ProjectPreviewStatusInput, ProjectPreviewStatusInputSchema, type ProjectReadFileInput, @@ -177,22 +165,6 @@ export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles { }; } - public async previewFile(input: ProjectPreviewFileInput): Promise { - const parsed = ProjectPreviewFileInputSchema.parse(input); - const extension = lowercaseExtension(parsed.path); - const imageMime = imageMimeType(extension); - if (imageMime) { - return this.base64Preview(parsed.path, parsed.path, "image", imageMime); - } - if (extension === ".pdf") { - return this.base64Preview(parsed.path, parsed.path, "pdf", "application/pdf"); - } - if (isOfficePreviewExtension(extension)) { - return this.officePdfPreview(parsed.path); - } - return unsupportedPreview(parsed.path, "No preview renderer is available for this file type."); - } - public async writeFile(input: ProjectWriteFileInput): Promise { const parsed = ProjectWriteFileInputSchema.parse(input); assertMutableWorkspacePath(parsed.path); @@ -437,70 +409,6 @@ export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles { }; } - private async base64Preview( - sourcePath: string, - previewPath: string, - kind: "image" | "pdf", - mimeType: string, - ): Promise { - const id = await this.ensureSandbox(); - let bytes: Uint8Array; - try { - bytes = await this.client().downloadFile(id, previewPath, MAX_PREVIEW_BYTES + 1); - } catch (error) { - if (isDaytonaResponseTooLarge(error)) { - return unsupportedPreview(sourcePath, "Preview file is too large to display inline."); - } - throw error; - } - if (bytes.byteLength > MAX_PREVIEW_BYTES) { - return unsupportedPreview(sourcePath, "Preview file is too large to display inline."); - } - return { - content: encodeBase64(bytes), - encoding: "base64", - error: null, - kind, - mimeType, - path: sourcePath, - previewPath, - }; - } - - private async officePdfPreview(sourcePath: string): Promise { - const id = await this.ensureSandbox(); - if (!(await this.hasLibreOfficeRuntime(id))) { - return unsupportedPreview( - sourcePath, - "Office preview requires the current sandbox image with LibreOffice installed.", - ); - } - const outputDir = `${PREVIEW_DIR}/${crypto.randomUUID()}`; - const command = [ - `mkdir -p ${shellQuote(outputDir)}`, - `libreoffice --headless --nologo --nofirststartwizard --convert-to pdf --outdir ${shellQuote( - outputDir, - )} ${shellQuote(sourcePath)}`, - ].join(" && "); - const converted = await this.client() - .execute(id, { command, cwd: WORKSPACE_DIR, timeout: timeoutSeconds(90_000) }) - .catch((error: unknown) => { - throw this.toUpstreamError(error, "Office preview conversion failed."); - }); - if (converted.exitCode !== 0) { - return unsupportedPreview(sourcePath, conversionErrorMessage(converted.result ?? "")); - } - const previewPath = `${outputDir}/${withoutExtension(basename(sourcePath))}.pdf`; - return this.base64Preview(sourcePath, previewPath, "pdf", "application/pdf"); - } - - private async hasLibreOfficeRuntime(id: string): Promise { - const probe = await this.client() - .execute(id, { command: "command -v libreoffice >/dev/null", timeout: 5 }) - .catch(() => null); - return probe?.exitCode === 0; - } - private async ensureMobileMetroForwardedHostConfig(id: string, cwd: string): Promise { const current = await this.client() .execute(id, { diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts b/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts new file mode 100644 index 00000000..8220f70b --- /dev/null +++ b/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts @@ -0,0 +1,74 @@ +import { APP_PREVIEW_SLOT_PREFIX } from "./project-sandbox-process-support"; +import { ProjectWorkspaceSlugSchema, workspaceSlugFromPath } from "./project-sandbox-runtime"; + +type LeasePolicy = readonly [ + kind: + | "cleanup-signal" + | "owner-registration" + | "project-cleanup" + | "sandbox" + | "shared-workspace" + | "streaming" + | "workspace", + slug?: "none" | "path" | "process-id" | "project-id" | "workspace-path" | "workspace-slug", +]; + +// biome-ignore format: Group the complete RPC policy surface by lease behavior for compact auditing. +const LEASE_POLICIES = { + registerOwner: ["owner-registration"], + setQuotaPeriod: ["sandbox"], beginRun: ["sandbox"], + renewRun: ["cleanup-signal"], endRun: ["cleanup-signal"], alarm: ["cleanup-signal"], + runtimeSandboxId: ["sandbox"], existingDaytonaId: ["sandbox"], sandboxRuntimeState: ["sandbox"], + ensureReady: ["sandbox"], getStatus: ["sandbox"], + runCode: ["workspace", "none"], exec: ["workspace", "none"], startProcess: ["workspace", "none"], + allocateProjectPort: ["workspace", "project-id"], + allocateProcessPort: ["workspace", "process-id"], + killAllProcesses: ["shared-workspace"], + killProcess: ["workspace", "process-id"], readDevServerLogs: ["workspace", "process-id"], + downloadProjectArchive: ["streaming", "workspace-slug"], + readFile: ["workspace", "path"], listUploadedFiles: ["sandbox"], + uploadProjectFile: ["workspace", "workspace-slug"], + restoreUploadedFiles: ["workspace", "workspace-slug"], + writeFile: ["workspace", "path"], listFiles: ["workspace", "path"], + searchFiles: ["workspace", "path"], deleteFile: ["workspace", "path"], + getSignedPreviewUrl: ["sandbox"], exposeBrowserTakeover: ["sandbox"], + stopBrowserTakeover: ["cleanup-signal"], + exposeCodeServer: ["workspace", "workspace-path"], + wakePreview: ["workspace", "workspace-slug"], + projectPreviewStatus: ["workspace", "workspace-slug"], + cleanupProjectWorkspace: ["project-cleanup"], +} as const satisfies Record; + +export type LeaseMethod = keyof typeof LEASE_POLICIES; + +export function leaseKind(method: LeaseMethod): LeasePolicy[0] { + return LEASE_POLICIES[method][0]; +} + +export function workspaceScope(method: LeaseMethod, input: unknown): string | null { + const [, source] = LEASE_POLICIES[method] as LeasePolicy; + if (!source || source === "none") return null; + if (source === "path") return workspaceSlugFromPath(stringField(input, "path")); + if (source === "workspace-path") + return workspaceSlugFromPath(stringField(input, "workspacePath")); + if (source === "process-id") return workspaceSlugFromProcessId(stringField(input, "processId")); + if (source === "project-id") return workspaceSlug(stringField(input, "projectId")); + return workspaceSlug(stringField(input, "workspaceSlug")); +} + +function workspaceSlug(value: string | undefined): string | null { + const parsed = ProjectWorkspaceSlugSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function workspaceSlugFromProcessId(processId: string | undefined): string | null { + return processId?.startsWith(APP_PREVIEW_SLOT_PREFIX) + ? workspaceSlug(processId.slice(APP_PREVIEW_SLOT_PREFIX.length)) + : null; +} + +function stringField(input: unknown, field: string): string | undefined { + if (typeof input !== "object" || input === null) return undefined; + const value = (input as Record)[field]; + return typeof value === "string" ? value : undefined; +} diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts index 7c97c05b..c3f5031e 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts @@ -2,6 +2,7 @@ import type { WorkerSecret } from "@cheatcode/env"; import { APIError } from "@cheatcode/observability"; import { DaytonaApiError, type DaytonaSandbox } from "@cheatcode/tools-code"; import { z } from "zod"; +import type { QuotaTrackerNamespace } from "../quota-tracker-binding"; export interface ProjectSandboxEnv { CHEATCODE_RELEASE_SHA?: string; @@ -16,7 +17,7 @@ export interface ProjectSandboxEnv { DAYTONA_PREVIEW_HOST_SUFFIXES?: string; PREVIEW_TOKEN_SECRET: WorkerSecret; PREVIEW_HOSTNAME: string; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: QuotaTrackerNamespace; R2_AUDIT: R2Bucket; R2_OUTPUTS: R2Bucket; } diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-metering.ts b/apps/agent-worker/src/durable-objects/project-sandbox-metering.ts index 0cb15702..175e0b34 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-metering.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-metering.ts @@ -1,16 +1,7 @@ -import { - createLogger, - readBoundedResponseJson, - safeErrorTelemetry, -} from "@cheatcode/observability"; -import { - QUOTA_FEATURES, - QUOTA_TRACKER_MAX_RESPONSE_BYTES, - QuotaPeriodEndSchema, - QuotaRecordRequestSchema, - QuotaUsageResponseSchema, -} from "@cheatcode/types/quota"; +import { createLogger, safeErrorTelemetry } from "@cheatcode/observability"; +import { QUOTA_FEATURES, QuotaPeriodEndSchema } from "@cheatcode/types/quota"; import { z } from "zod"; +import type { QuotaTrackerNamespace } from "../quota-tracker-binding"; const SANDBOX_METER_STATE_KEY = "sandbox_meter_state"; const SANDBOX_QUOTA_PERIOD_END_KEY = "sandbox_quota_period_end"; @@ -40,7 +31,7 @@ type MeterState = z.infer; type PendingAccrual = z.infer; interface SandboxMeteringEnv { - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: QuotaTrackerNamespace; } export interface SandboxMeteringContext { @@ -209,28 +200,17 @@ async function stageNextAccrual( } async function sendAccrual( - namespace: DurableObjectNamespace, + namespace: QuotaTrackerNamespace, ownerUserId: string, pending: PendingAccrual, ): Promise { const stub = namespace.get(namespace.idFromName(`quota:${ownerUserId}`)); - const body = QuotaRecordRequestSchema.parse({ - amount: pending.amountMs / MILLIS_PER_HOUR, - eventId: pending.eventId, - feature: QUOTA_FEATURES.sandboxHours, - periodEnd: pending.periodEnd, - recordedAt: new Date(pending.recordedAtMs).toISOString(), - }); - const response = await stub.fetch("https://quota.internal/record", { - body: JSON.stringify(body), - method: "POST", - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new Error(`QuotaTracker record failed with HTTP ${response.status}`); - } - QuotaUsageResponseSchema.parse( - await readBoundedResponseJson(response, QUOTA_TRACKER_MAX_RESPONSE_BYTES, "Quota record"), + await stub.record( + QUOTA_FEATURES.sandboxHours, + pending.amountMs / MILLIS_PER_HOUR, + new Date(pending.periodEnd), + pending.eventId, + new Date(pending.recordedAtMs), ); } diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts b/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts index a09c5410..323d7d3f 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts @@ -50,8 +50,6 @@ import { ProjectAllocateProcessPortInputSchema, type ProjectExecInput, ProjectExecInputSchema, - type ProjectGetPortInput, - ProjectGetPortInputSchema, type ProjectKillProcessInput, ProjectKillProcessInputSchema, type ProjectReadDevServerLogsInput, @@ -227,14 +225,6 @@ export abstract class ProjectSandboxProcesses extends ProjectSandboxLifecycle { }); } - public async getProjectPort(input: ProjectGetPortInput): Promise { - const parsed = ProjectGetPortInputSchema.parse(input); - const allocation = PortAllocationSchema.parse( - (await this.ctx.storage.get(PORT_ALLOC_KEY)) ?? {}, - ); - return allocation.ports[parsed.projectId] ?? null; - } - public async allocateProcessPort(input: ProjectAllocateProcessPortInput): Promise { const parsed = ProjectAllocateProcessPortInputSchema.parse(input); return this.ctx.storage.transaction(async (transaction) => { diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts b/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts index 667d78c3..dfd0513b 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts @@ -69,12 +69,6 @@ export const ProjectReadFileInputSchema = z }) .strict(); -export const ProjectPreviewFileInputSchema = z - .object({ - path: WorkspaceFilePathSchema, - }) - .strict(); - export const ProjectWriteFileInputSchema = z .object({ path: WorkspaceFilePathSchema, @@ -171,12 +165,6 @@ export const ProjectAllocatePortInputSchema = z }) .strict(); -export const ProjectGetPortInputSchema = z - .object({ - projectId: ProjectWorkspaceSlugSchema, - }) - .strict(); - export const ProjectAllocateProcessPortInputSchema = z .object({ maxPort: z.number().int().min(1_024).max(65_535), @@ -260,7 +248,6 @@ export const ProjectArchiveInputSchema = z export type ProjectExecInput = z.input; export type ProjectStartProcessInput = z.input; -export type ProjectPreviewFileInput = z.input; export type ProjectReadFileInput = z.input; export type ProjectWriteFileInput = z.input; export type ProjectUploadFileInput = z.input; @@ -273,7 +260,6 @@ export type ProjectSearchFilesInput = z.input; export type ProjectKillProcessInput = z.input; export type ProjectAllocatePortInput = z.input; -export type ProjectGetPortInput = z.input; export type ProjectAllocateProcessPortInput = z.input; export type ProjectCodeServerInput = z.input; export type ProjectWakePreviewInput = z.input; diff --git a/apps/agent-worker/src/durable-objects/project-sandbox.ts b/apps/agent-worker/src/durable-objects/project-sandbox.ts index 7fd2e414..d99f193a 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox.ts @@ -1,284 +1,86 @@ import { ProjectSandboxContent } from "./project-sandbox-content"; -import { APP_PREVIEW_SLOT_PREFIX } from "./project-sandbox-process-support"; -import { ProjectWorkspaceSlugSchema, workspaceSlugFromPath } from "./project-sandbox-runtime"; +import { type LeaseMethod, leaseKind, workspaceScope } from "./project-sandbox-lease-policy"; + +type MethodArgs = ProjectSandboxContent[Method] extends ( + ...args: infer Args +) => unknown + ? Args + : never; /** - * Public Durable Object facade. Every operational RPC takes an in-memory lease - * before its first await so account deletion can fence new work and drain old - * work without holding blockConcurrencyWhile across Daytona requests. + * Public Durable Object facade. Every operational RPC takes its table-selected + * in-memory lease before the first await so account deletion can drain safely. */ +// biome-ignore format: Keep the explicit Durable Object RPC facade as an auditable one-line policy map. export class ProjectSandbox extends ProjectSandboxContent { - public override registerOwner( - ...args: Parameters - ): ReturnType { - return this.withActiveOwnerRegistration(args[0], () => super.registerOwner(...args)); - } - - public override setQuotaPeriod( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.setQuotaPeriod(...args)); - } - - public override beginRun( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.beginRun(...args)); - } - - public override renewRun( - ...args: Parameters - ): ReturnType { - // Cleanup signals remain admissible while account deletion drains work so - // late lease renewal and alarm delivery cannot strand Daytona activity. - return this.withActiveSandboxCleanupSignal(() => super.renewRun(...args)); - } - - public override endRun( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxCleanupSignal(() => super.endRun(...args)); - } - - public override alarm( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxCleanupSignal(() => super.alarm(...args)); - } - - public override runtimeSandboxId( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.runtimeSandboxId(...args)); - } - - public override existingDaytonaId( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.existingDaytonaId(...args)); - } - - public override sandboxRuntimeState( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.sandboxRuntimeState(...args)); - } - - public override ensureReady( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.ensureReady(...args)); - } - - public override getStatus( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.getStatus(...args)); - } - - public override runCode( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(null, () => super.runCode(...args)); - } - - public override exec( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(null, () => super.exec(...args)); - } - - public override startProcess( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(null, () => super.startProcess(...args)); - } - - public override allocateProjectPort( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].projectId), () => - super.allocateProjectPort(...args), - ); - } - - public override getProjectPort( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].projectId), () => - super.getProjectPort(...args), - ); - } - - public override allocateProcessPort( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation( - workspaceSlugFromProcessId(args[0].processId), - () => super.allocateProcessPort(...args), - ); - } - - public override killAllProcesses( - ...args: Parameters - ): ReturnType { - return this.withActiveSharedWorkspaceMutation(() => super.killAllProcesses(...args)); - } - - public override killProcess( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation( - workspaceSlugFromProcessId(args[0].processId), - () => super.killProcess(...args), - ); - } - - public override readDevServerLogs( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation( - workspaceSlugFromProcessId(args[0].processId), - () => super.readDevServerLogs(...args), - ); + public override registerOwner(...args: MethodArgs<"registerOwner">) { return this.withLease("registerOwner", args[0], () => super.registerOwner(...args)); } + public override setQuotaPeriod(...args: MethodArgs<"setQuotaPeriod">) { return this.withLease("setQuotaPeriod", args[0], () => super.setQuotaPeriod(...args)); } + public override beginRun(...args: MethodArgs<"beginRun">) { return this.withLease("beginRun", args[0], () => super.beginRun(...args)); } + public override renewRun(...args: MethodArgs<"renewRun">) { return this.withLease("renewRun", args[0], () => super.renewRun(...args)); } + public override endRun(...args: MethodArgs<"endRun">) { return this.withLease("endRun", args[0], () => super.endRun(...args)); } + public override alarm(...args: MethodArgs<"alarm">) { return this.withLease("alarm", undefined, () => super.alarm(...args)); } + public override runtimeSandboxId(...args: MethodArgs<"runtimeSandboxId">) { return this.withLease("runtimeSandboxId", undefined, () => super.runtimeSandboxId(...args)); } + public override existingDaytonaId(...args: MethodArgs<"existingDaytonaId">) { return this.withLease("existingDaytonaId", undefined, () => super.existingDaytonaId(...args)); } + public override sandboxRuntimeState(...args: MethodArgs<"sandboxRuntimeState">) { return this.withLease("sandboxRuntimeState", undefined, () => super.sandboxRuntimeState(...args)); } + public override ensureReady(...args: MethodArgs<"ensureReady">) { return this.withLease("ensureReady", undefined, () => super.ensureReady(...args)); } + public override getStatus(...args: MethodArgs<"getStatus">) { return this.withLease("getStatus", undefined, () => super.getStatus(...args)); } + public override runCode(...args: MethodArgs<"runCode">) { return this.withLease("runCode", args[0], () => super.runCode(...args)); } + public override exec(...args: MethodArgs<"exec">) { return this.withLease("exec", args[0], () => super.exec(...args)); } + public override startProcess(...args: MethodArgs<"startProcess">) { return this.withLease("startProcess", args[0], () => super.startProcess(...args)); } + public override allocateProjectPort(...args: MethodArgs<"allocateProjectPort">) { return this.withLease("allocateProjectPort", args[0], () => super.allocateProjectPort(...args)); } + public override allocateProcessPort(...args: MethodArgs<"allocateProcessPort">) { return this.withLease("allocateProcessPort", args[0], () => super.allocateProcessPort(...args)); } + public override killAllProcesses(...args: MethodArgs<"killAllProcesses">) { return this.withLease("killAllProcesses", undefined, () => super.killAllProcesses(...args)); } + public override killProcess(...args: MethodArgs<"killProcess">) { return this.withLease("killProcess", args[0], () => super.killProcess(...args)); } + public override readDevServerLogs(...args: MethodArgs<"readDevServerLogs">) { return this.withLease("readDevServerLogs", args[0], () => super.readDevServerLogs(...args)); } + public override downloadProjectArchive(...args: MethodArgs<"downloadProjectArchive">) { return this.withActiveProjectWorkspaceStreamingOperation(workspaceScope("downloadProjectArchive", args[0]), (release) => super.downloadProjectArchiveForRpc(args[0], release)); } + public override readFile(...args: MethodArgs<"readFile">) { return this.withLease("readFile", args[0], () => super.readFile(...args)); } + public override listUploadedFiles(...args: MethodArgs<"listUploadedFiles">) { return this.withLease("listUploadedFiles", undefined, () => super.listUploadedFiles(...args)); } + public override uploadProjectFile(...args: MethodArgs<"uploadProjectFile">) { return this.withLease("uploadProjectFile", args[0], () => super.uploadProjectFile(...args)); } + public override restoreUploadedFiles(...args: MethodArgs<"restoreUploadedFiles">) { return this.withLease("restoreUploadedFiles", args[0], () => super.restoreUploadedFiles(...args)); } + public override writeFile(...args: MethodArgs<"writeFile">) { return this.withLease("writeFile", args[0], () => super.writeFile(...args)); } + public override listFiles(...args: MethodArgs<"listFiles">) { return this.withLease("listFiles", args[0], () => super.listFiles(...args)); } + public override searchFiles(...args: MethodArgs<"searchFiles">) { return this.withLease("searchFiles", args[0], () => super.searchFiles(...args)); } + public override deleteFile(...args: MethodArgs<"deleteFile">) { return this.withLease("deleteFile", args[0], () => super.deleteFile(...args)); } + public override getSignedPreviewUrl(...args: MethodArgs<"getSignedPreviewUrl">) { return this.withLease("getSignedPreviewUrl", args[0], () => super.getSignedPreviewUrl(...args)); } + public override exposeBrowserTakeover(...args: MethodArgs<"exposeBrowserTakeover">) { return this.withLease("exposeBrowserTakeover", args[0], () => super.exposeBrowserTakeover(...args)); } + public override stopBrowserTakeover(...args: MethodArgs<"stopBrowserTakeover">) { return this.withLease("stopBrowserTakeover", args[0], () => super.stopBrowserTakeover(...args)); } + public override exposeCodeServer(...args: MethodArgs<"exposeCodeServer">) { return this.withLease("exposeCodeServer", args[0], () => super.exposeCodeServer(...args)); } + public override wakePreview(...args: MethodArgs<"wakePreview">) { return this.withLease("wakePreview", args[0], () => super.wakePreview(...args)); } + public override projectPreviewStatus(...args: MethodArgs<"projectPreviewStatus">) { return this.withLease("projectPreviewStatus", args[0], () => super.projectPreviewStatus(...args)); } + public override cleanupProjectWorkspace(...args: MethodArgs<"cleanupProjectWorkspace">) { return this.withLease("cleanupProjectWorkspace", args[0], () => super.cleanupProjectWorkspace(...args)); } + + private withLease( + method: Exclude, + input: unknown, + operation: () => Promise, + ): Promise; + private withLease( + method: Exclude, + input: unknown, + operation: () => Promise, + ): Promise { + const kind = leaseKind(method); + if (kind === "owner-registration") { + if (typeof input !== "string") throw new TypeError("Expected string RPC argument"); + return this.withActiveOwnerRegistration(input, operation); + } + if (kind === "cleanup-signal") { + // The signal wrapper is void-typed; capture the value so the overload's + // Promise contract holds if a signal method ever returns one. + let result: unknown; + return this.withActiveSandboxCleanupSignal(async () => { + result = await operation(); + }).then(() => result); + } + if (kind === "shared-workspace") { + return this.withActiveSharedWorkspaceMutation(operation); + } + if (kind === "project-cleanup") { + return this.withActiveProjectWorkspaceCleanup(operation); + } + if (kind === "workspace") { + return this.withActiveProjectWorkspaceOperation(workspaceScope(method, input), operation); + } + return this.withActiveSandboxOperation(operation); } - - public override downloadProjectArchive( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceStreamingOperation( - workspaceSlug(args[0].workspaceSlug), - (release) => super.downloadProjectArchiveForRpc(args[0], release), - ); - } - - public override readFile( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlugFromPath(args[0].path), () => - super.readFile(...args), - ); - } - - public override listUploadedFiles( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.listUploadedFiles(...args)); - } - - public override uploadProjectFile( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].workspaceSlug), () => - super.uploadProjectFile(...args), - ); - } - - public override restoreUploadedFiles( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].workspaceSlug), () => - super.restoreUploadedFiles(...args), - ); - } - - public override previewFile( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlugFromPath(args[0].path), () => - super.previewFile(...args), - ); - } - - public override writeFile( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlugFromPath(args[0].path), () => - super.writeFile(...args), - ); - } - - public override listFiles( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlugFromPath(args[0].path), () => - super.listFiles(...args), - ); - } - - public override searchFiles( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlugFromPath(args[0].path), () => - super.searchFiles(...args), - ); - } - - public override deleteFile( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlugFromPath(args[0].path), () => - super.deleteFile(...args), - ); - } - - public override getSignedPreviewUrl( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.getSignedPreviewUrl(...args)); - } - - public override exposeBrowserTakeover( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxOperation(() => super.exposeBrowserTakeover(...args)); - } - - public override stopBrowserTakeover( - ...args: Parameters - ): ReturnType { - return this.withActiveSandboxCleanupSignal(() => super.stopBrowserTakeover(...args)); - } - - public override exposeCodeServer( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation( - workspaceSlugFromPath(args[0].workspacePath), - () => super.exposeCodeServer(...args), - ); - } - - public override wakePreview( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].workspaceSlug), () => - super.wakePreview(...args), - ); - } - - public override projectPreviewStatus( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].workspaceSlug), () => - super.projectPreviewStatus(...args), - ); - } - - public override cleanupProjectWorkspace( - ...args: Parameters - ): ReturnType { - return this.withActiveProjectWorkspaceCleanup(() => super.cleanupProjectWorkspace(...args)); - } -} - -function workspaceSlug(value: string | undefined): string | null { - const parsed = ProjectWorkspaceSlugSchema.safeParse(value); - return parsed.success ? parsed.data : null; -} - -function workspaceSlugFromProcessId(processId: string | undefined): string | null { - return processId?.startsWith(APP_PREVIEW_SLOT_PREFIX) - ? workspaceSlug(processId.slice(APP_PREVIEW_SLOT_PREFIX.length)) - : null; } diff --git a/apps/agent-worker/src/error-handling.ts b/apps/agent-worker/src/error-handling.ts index bafe5cf9..c07f2df6 100644 --- a/apps/agent-worker/src/error-handling.ts +++ b/apps/agent-worker/src/error-handling.ts @@ -2,11 +2,15 @@ import { APIError, toAPIError } from "@cheatcode/observability"; import { z } from "zod"; export function formatAgentRouteError(error: unknown, requestId: string): Response { + return toAgentRouteError(error).toResponse(requestId); +} + +export function toAgentRouteError(error: unknown): APIError { if (error instanceof z.ZodError) { return new APIError(400, "invalid_request_body", "Invalid request payload", { details: { issues: error.issues.map((issue) => issue.message) }, retriable: false, - }).toResponse(requestId); + }); } - return toAPIError(error).toResponse(requestId); + return toAPIError(error); } diff --git a/apps/agent-worker/src/index.ts b/apps/agent-worker/src/index.ts index 168794e4..a4a2e279 100644 --- a/apps/agent-worker/src/index.ts +++ b/apps/agent-worker/src/index.ts @@ -1,11 +1,9 @@ import { AgentWorkerEnvSchema } from "@cheatcode/env"; import { - createLogger, - emitErrorEvent, - emitPerformanceMetric, - safeErrorTelemetry, - toAPIError, - withErrorHandler, + createPerformanceMetricMiddleware, + createWorkerRuntime, + requestId, + routeWorkerError, } from "@cheatcode/observability"; import { normalizeTelemetryPath } from "@cheatcode/types"; import { type Context, Hono } from "hono"; @@ -17,7 +15,7 @@ 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 { formatAgentRouteError } from "./error-handling"; +import { formatAgentRouteError, toAgentRouteError } from "./error-handling"; import { registerSandboxHttpRoutes } from "./sandbox-http-routes"; import { registerSkillRuntimeExecutionRoutes } from "./skill-runtime-execution-routes"; import { registerSkillRuntimeManagedRoutes } from "./skill-runtime-managed-routes"; @@ -27,37 +25,18 @@ export { AgentLifecycleEntrypoint, AgentRun, AgentRunWorkflow, ProjectSandbox }; export const agentApp = new Hono<{ Bindings: AgentEnv }>(); -agentApp.onError((error, c) => { - const id = c.req.header("X-Request-Id") ?? requestId(); - const apiError = toAPIError(error); - const route = registeredRouteName(c); - logAgentRequestError(error, id, route); - emitErrorEvent(c.env, { - errorCategory: "agent", - errorCode: apiError.code, - httpStatus: apiError.status, - route, - workerName: "agent", - ...safeErrorTelemetry(error), - }); - return formatAgentRouteError(error, id); +agentApp.onError((error, context) => { + throw routeWorkerError(error, registeredRouteName(context)); }); -agentApp.use("*", async (c, next) => { - const startedAt = performance.now(); - let status = 500; - try { - await next(); - status = c.res.status; - } finally { - emitPerformanceMetric(c.env, { - route: registeredRouteName(c), - statusClass: statusClass(status), - totalMs: performance.now() - startedAt, - workerName: "agent", - }); - } -}); +agentApp.use( + "*", + createPerformanceMetricMiddleware>({ + errorStatus: (error) => toAgentRouteError(error).status, + routeName: registeredRouteName, + workerName: "agent", + }), +); agentApp.get("/health", (c) => c.json({ @@ -75,54 +54,19 @@ registerUserSkillHttpRoutes(agentApp); registerSkillRuntimeManagedRoutes(agentApp); registerSkillRuntimeExecutionRoutes(agentApp); -const agentHandler = { - async fetch(request: Request, env: AgentEnv, ctx: ExecutionContext): Promise { +const agentHandler = createWorkerRuntime({ + errorCategory: "agent", + errorLogFields: ({ route }) => ({ route, workerName: "agent" }), + errorLogName: "agent_request_failed", + fetch: async (request, env, ctx) => { AgentWorkerEnvSchema.parse(env); - const id = request.headers.get("X-Request-Id") ?? requestId(); - try { - const requestWithId = isWebSocketUpgrade(request) ? request : new Request(request); - if (!isWebSocketUpgrade(requestWithId)) { - requestWithId.headers.set("X-Request-Id", id); - } - return withRequestId(await agentApp.fetch(requestWithId, env, ctx), id); - } catch (error) { - const apiError = toAPIError(error); - logAgentRequestError(error, id, routeName(request)); - emitErrorEvent(env, { - errorCategory: "agent", - errorCode: apiError.code, - httpStatus: apiError.status, - route: routeName(request), - workerName: "agent", - ...safeErrorTelemetry(error), - }); - return apiError.toResponse(id); - } + return agentApp.fetch(request, env, ctx); }, -}; - -function logAgentRequestError(error: unknown, requestIdValue: string, route: string): void { - const apiError = toAPIError(error); - createLogger({ requestId: requestIdValue }).error("agent_request_failed", { - apiCode: apiError.code, - route, - workerName: "agent", - ...safeErrorTelemetry(error), - }); -} - -function requestId(): string { - return `req_${crypto.randomUUID().replaceAll("-", "")}`; -} - -function withRequestId(response: Response, id: string): Response { - if (response.status === 101 || response.webSocket) { - return response; - } - const wrapped = new Response(response.body, response); - wrapped.headers.set("X-Request-Id", id); - return wrapped; -} + formatError: ({ error, requestId: id }) => formatAgentRouteError(error, id), + requestId: (request) => request.headers.get("X-Request-Id") ?? requestId(), + routeName, + workerName: "agent", +}); function routeName(request: Request): string { const url = new URL(request.url); @@ -137,26 +81,4 @@ function registeredRouteName(c: Context<{ Bindings: AgentEnv }>): string { } } -function statusClass(status: number): string { - if (status >= 500) { - return "5xx"; - } - if (status >= 400) { - return "4xx"; - } - if (status >= 300) { - return "3xx"; - } - return "2xx"; -} - -function isWebSocketUpgrade(request: Request): boolean { - return (request.headers.get("Upgrade") ?? "").toLowerCase() === "websocket"; -} - -export default withErrorHandler(agentHandler, { - errorCategory: "agent", - requestId: (request) => request.headers.get("X-Request-Id"), - routeName, - workerName: "agent", -}); +export default agentHandler; diff --git a/apps/agent-worker/src/quota-tracker-binding.ts b/apps/agent-worker/src/quota-tracker-binding.ts new file mode 100644 index 00000000..af151e77 --- /dev/null +++ b/apps/agent-worker/src/quota-tracker-binding.ts @@ -0,0 +1,6 @@ +import type { DurableObject } from "cloudflare:workers"; +import type { QuotaTrackerRpc } from "@cheatcode/types/quota"; + +type QuotaTrackerObject = DurableObject & QuotaTrackerRpc; +export type QuotaTrackerNamespace = DurableObjectNamespace; +export type QuotaTrackerStub = DurableObjectStub; diff --git a/apps/agent-worker/src/sandbox-file-http-routes.ts b/apps/agent-worker/src/sandbox-file-http-routes.ts deleted file mode 100644 index a891b572..00000000 --- a/apps/agent-worker/src/sandbox-file-http-routes.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { workspacePathForSlug } from "@cheatcode/db"; -import { readJsonRequest } from "@cheatcode/observability"; -import { resolveProjectWorkspacePath } from "@cheatcode/tools-code"; -import { - SandboxFileListSchema, - SandboxFilePathSchema, - SandboxFilePreviewSchema, - SandboxFileSchema, - SandboxFileWriteSchema, - UpdateSandboxPathFileSchema, -} from "@cheatcode/types"; -import type { Context, Hono } from "hono"; -import { z } from "zod"; -import type { AgentEnv } from "./agent-env"; -import { requireWritableThreadProject, sandboxForUser } from "./agent-routing"; -import { SANDBOX_WORKSPACE_ROOT, terminalProjectForThread } from "./sandbox-route-helpers"; -import { parseThreadRouteParam, readGatewayUserId } from "./tenancy"; - -const DEFAULT_SANDBOX_FILE_LIST_ROOT = "/workspace"; -const MAX_SANDBOX_FILE_WRITE_REQUEST_BYTES = 10 * 1024 * 1024; -const QueryBooleanSchema = z.enum(["false", "true"]).transform((value) => value === "true"); -const SandboxFileListQuerySchema = z - .object({ - includeHidden: QueryBooleanSchema.default(false), - path: SandboxFilePathSchema.default(DEFAULT_SANDBOX_FILE_LIST_ROOT), - recursive: QueryBooleanSchema.default(true), - }) - .strict(); -const SandboxReadEncodingQuerySchema = z.enum(["utf8", "base64"]).optional(); - -type AgentContext = Context<{ Bindings: AgentEnv }>; - -export function registerSandboxFileHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { - app.get("/v1/threads/:threadId/sandbox/files", listSandboxFiles); - app.get("/v1/threads/:threadId/sandbox/file", readSandboxFile); - app.get("/v1/threads/:threadId/sandbox/file-preview", previewSandboxFile); - app.patch("/v1/threads/:threadId/sandbox/file", writeSandboxFile); -} - -async function listSandboxFiles(c: AgentContext): Promise { - const userId = readGatewayUserId(c.req.raw.headers); - const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); - const query = SandboxFileListQuerySchema.parse({ - includeHidden: c.req.query("includeHidden") ?? undefined, - path: c.req.query("path") ?? undefined, - recursive: c.req.query("recursive") ?? undefined, - }); - const scoped = await scopedThreadSandboxPath(c.env, userId, threadId, query.path); - const result = await scoped.sandbox.listFiles({ ...query, path: scoped.path }); - return c.json(SandboxFileListSchema.parse(result)); -} - -async function readSandboxFile(c: AgentContext): Promise { - const userId = readGatewayUserId(c.req.raw.headers); - const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); - const path = SandboxFilePathSchema.parse(c.req.query("path")); - const encoding = SandboxReadEncodingQuerySchema.parse(c.req.query("encoding") ?? undefined); - const scoped = await scopedThreadSandboxPath(c.env, userId, threadId, path); - const file = await scoped.sandbox.readFile({ - path: scoped.path, - ...(encoding ? { encoding } : {}), - }); - return c.json(SandboxFileSchema.parse(file)); -} - -async function previewSandboxFile(c: AgentContext): Promise { - const userId = readGatewayUserId(c.req.raw.headers); - const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); - const path = SandboxFilePathSchema.parse(c.req.query("path")); - const scoped = await scopedThreadSandboxPath(c.env, userId, threadId, path); - const preview = await scoped.sandbox.previewFile({ path: scoped.path }); - return c.json(SandboxFilePreviewSchema.parse(preview)); -} - -async function writeSandboxFile(c: AgentContext): Promise { - const userId = readGatewayUserId(c.req.raw.headers); - const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); - const body = UpdateSandboxPathFileSchema.parse( - await readJsonRequest(c.req.raw, MAX_SANDBOX_FILE_WRITE_REQUEST_BYTES, "Sandbox file write"), - ); - await requireWritableThreadProject(c.env, userId, threadId); - const scoped = await scopedThreadSandboxPath(c.env, userId, threadId, body.path); - const result = await scoped.sandbox.writeFile({ - content: body.content, - encoding: body.encoding, - path: scoped.path, - }); - return c.json(SandboxFileWriteSchema.parse(result)); -} - -async function scopedThreadSandboxPath( - env: AgentEnv, - userId: string, - threadId: string, - path: string, -): Promise<{ - path: string; - sandbox: Awaited>; -}> { - const project = await terminalProjectForThread(env, userId, threadId); - const workspaceDir = project - ? workspacePathForSlug(project.workspaceSlug) - : SANDBOX_WORKSPACE_ROOT; - return { - path: resolveProjectWorkspacePath(path, workspaceDir), - sandbox: await sandboxForUser(env, userId), - }; -} diff --git a/apps/agent-worker/src/sandbox-http-routes.ts b/apps/agent-worker/src/sandbox-http-routes.ts index a28b3ce7..715dd8dd 100644 --- a/apps/agent-worker/src/sandbox-http-routes.ts +++ b/apps/agent-worker/src/sandbox-http-routes.ts @@ -1,13 +1,11 @@ import type { Hono } from "hono"; import type { AgentEnv } from "./agent-env"; import { registerProjectFileHttpRoutes } from "./project-file-http-routes"; -import { registerSandboxFileHttpRoutes } from "./sandbox-file-http-routes"; import { registerSandboxPreviewHttpRoutes } from "./sandbox-preview-http-routes"; import { registerSandboxTerminalHttpRoutes } from "./sandbox-terminal-http-routes"; export function registerSandboxHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { registerProjectFileHttpRoutes(app); - registerSandboxFileHttpRoutes(app); registerSandboxPreviewHttpRoutes(app); registerSandboxTerminalHttpRoutes(app); } diff --git a/apps/agent-worker/src/skill-runtime-auth.ts b/apps/agent-worker/src/skill-runtime-auth.ts index a7e6edba..bd55d74a 100644 --- a/apps/agent-worker/src/skill-runtime-auth.ts +++ b/apps/agent-worker/src/skill-runtime-auth.ts @@ -1,11 +1,7 @@ -import { - parseSkillRuntimeCapability, - type SkillRuntimeScope, - verifySkillRuntimeCapabilityDigest, -} from "@cheatcode/auth"; +import { parseSkillRuntimeCapability, verifySkillRuntimeCapabilityDigest } from "@cheatcode/auth"; import { authorizeSkillRuntimeCapability, createDb, withUserContext } from "@cheatcode/db"; import { APIError } from "@cheatcode/observability"; -import { AgentRunId, UserId } from "@cheatcode/types"; +import { AgentRunId, type SkillRuntimeScope, UserId } from "@cheatcode/types"; import type { AgentEnv } from "./agent-env"; export interface SkillRuntimePrincipal { diff --git a/apps/agent-worker/src/skill-runtime-execution-routes.ts b/apps/agent-worker/src/skill-runtime-execution-routes.ts index 2867f1c7..d414a958 100644 --- a/apps/agent-worker/src/skill-runtime-execution-routes.ts +++ b/apps/agent-worker/src/skill-runtime-execution-routes.ts @@ -4,7 +4,7 @@ import { IntegrationNameSchema } from "@cheatcode/types/integrations"; import type { Context, Hono } from "hono"; import { z } from "zod"; import type { AgentEnv } from "./agent-env"; -import { agentRunForRunId } from "./agent-routing"; +import { agentRunForRunId, callAgentRun } from "./agent-routing"; import { resolveComposioRuntimeCredentials } from "./durable-objects/composio-provider"; import { requireSkillRuntimePrincipal } from "./skill-runtime-auth"; @@ -127,12 +127,8 @@ async function acceptFrontendEvent(c: AgentContext): Promise { async function startBrowserTakeover(c: AgentContext): Promise { const principal = await requireSkillRuntimePrincipal(c.env, c.req.raw.headers, "events:write"); - return agentRunForRunId(c.env, principal.runId).fetch( - "https://agent-run.internal/browser-takeover/start", - { - headers: { "X-Cheatcode-User-Id": principal.userId }, - method: "POST", - }, + return callAgentRun( + agentRunForRunId(c.env, principal.runId).browserTakeoverStart(principal.userId), ); } diff --git a/apps/gateway-worker/README.md b/apps/gateway-worker/README.md index 309460fb..4f68ea39 100644 --- a/apps/gateway-worker/README.md +++ b/apps/gateway-worker/README.md @@ -102,18 +102,14 @@ 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. -`/v1/tools` and `/v1/agents` read the shared framework-free capability catalog -from `@cheatcode/types`. The Mastra registries are statically constrained to the -same exact names; workflows are exposed through tools and are not reported as -agents. Each tool summary also declares whether it uses the sandbox and whether -it produces an artifact; AgentRun stream status and deliverable routing derive -from those same traits instead of maintaining parallel tool-name lists. +The shared framework-free tool capability catalog in `@cheatcode/types` +statically constrains the Mastra tool registry to the same exact names. Each +tool summary also declares whether it uses the sandbox and whether it produces +an artifact; AgentRun stream status and deliverable routing derive from those +same traits instead of maintaining parallel tool-name lists. ## Public exports -- `gatewayApp` -- `gatewayRoutes` -- `GatewayAppType` - `IdempotencyStore` - `QuotaTracker` - `RateLimiter` diff --git a/apps/gateway-worker/package.json b/apps/gateway-worker/package.json index 11e55423..cc1a36e8 100644 --- a/apps/gateway-worker/package.json +++ b/apps/gateway-worker/package.json @@ -7,7 +7,6 @@ "types": "./src/index.ts", "scripts": { "build": "wrangler deploy --dry-run", - "deploy": "wrangler deploy", "lint": "biome check .", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/gateway-worker/src/activity-routes.ts b/apps/gateway-worker/src/activity-routes.ts index 2b18a2d1..7a4fdbcf 100644 --- a/apps/gateway-worker/src/activity-routes.ts +++ b/apps/gateway-worker/src/activity-routes.ts @@ -6,7 +6,7 @@ import { withUserContext, } from "@cheatcode/db"; import type { WorkerSecret } from "@cheatcode/env"; -import { APIError, readBoundedResponseJson } from "@cheatcode/observability"; +import { APIError } from "@cheatcode/observability"; import { type ActivityHistoryResponse, ActivityHistoryResponseSchema, @@ -16,7 +16,6 @@ import { import { QUOTA_FEATURES } from "@cheatcode/types/quota"; import type { z } from "zod"; import type { QuotaTracker } from "./durable-objects/quota-tracker"; -import { QuotaHistoryResultSchema } from "./durable-objects/quota-tracker-contract"; import type { WaitUntilContext } from "./wait-until-context"; export interface ActivityRouteEnv { @@ -30,7 +29,6 @@ const MS_PER_DAY = 86_400_000; const RUN_POINT_DISPLAY_CAP = 2_000; /** Request one past the display cap so a clipped range is detectable. */ const RUN_POINT_REQUEST_LIMIT = RUN_POINT_DISPLAY_CAP + 1; -const MAX_QUOTA_HISTORY_RESPONSE_BYTES = 64 * 1024; export async function getActivityHistoryRoute( env: ActivityRouteEnv, @@ -72,22 +70,19 @@ async function buildActivityResponse( async function listSandboxHourHistory(env: ActivityRouteEnv, userId: UserId, days: number) { const stub = env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`)); - const response = await stub.fetch("https://quota.internal/history", { - body: JSON.stringify({ - feature: QUOTA_FEATURES.sandboxHours, - from: new Date(Date.now() - days * MS_PER_DAY).toISOString(), - }), - method: "POST", - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); + let history: Awaited>; + try { + history = await stub.history( + QUOTA_FEATURES.sandboxHours, + new Date(Date.now() - days * MS_PER_DAY), + ); + } catch (error) { throw new APIError(503, "unavailable_maintenance", "Sandbox activity is unavailable", { + cause: error, retriable: true, }); } - return QuotaHistoryResultSchema.parse( - await readBoundedResponseJson(response, MAX_QUOTA_HISTORY_RESPONSE_BYTES, "Quota history"), - ).map((point) => ({ + return history.map((point) => ({ hours: point.amount, recordedAt: new Date(point.recordedAt).toISOString(), })); diff --git a/apps/gateway-worker/src/agent-http-routes.ts b/apps/gateway-worker/src/agent-http-routes.ts index ed3b70a5..da78bd07 100644 --- a/apps/gateway-worker/src/agent-http-routes.ts +++ b/apps/gateway-worker/src/agent-http-routes.ts @@ -18,26 +18,19 @@ export function registerAgentHttpRoutes(app: GatewayApp): void { for (const [path, route] of POST_AGENT_ROUTES) { app.post(path, (c) => forwardAgentRequest(c, route)); } - app.patch("/v1/threads/:threadId/sandbox/file", (c) => - forwardAgentRequest(c, "PATCH /v1/threads/:threadId/sandbox/file"), - ); app.get("/v1/threads/:threadId/sandbox/console", (c) => readSandboxConsoleRoute(c)); } const GET_AGENT_ROUTES = [ ["/v1/threads/:threadId/runs/stream", "GET /v1/threads/:threadId/runs/stream"], - ["/v1/threads/:threadId/runs/status", "GET /v1/threads/:threadId/runs/status"], ["/v1/threads/:threadId/browser-takeover", "GET /v1/threads/:threadId/browser-takeover"], ["/v1/computer/ide", "GET /v1/computer/ide"], ["/v1/computer/terminal/context", "GET /v1/computer/terminal/context"], - ["/v1/threads/:threadId/sandbox/files", "GET /v1/threads/:threadId/sandbox/files"], ["/v1/threads/:threadId/sandbox/ide", "GET /v1/threads/:threadId/sandbox/ide"], [ "/v1/threads/:threadId/sandbox/preview/status", "GET /v1/threads/:threadId/sandbox/preview/status", ], - ["/v1/threads/:threadId/sandbox/file", "GET /v1/threads/:threadId/sandbox/file"], - ["/v1/threads/:threadId/sandbox/file-preview", "GET /v1/threads/:threadId/sandbox/file-preview"], [ "/v1/threads/:threadId/sandbox/terminal/context", "GET /v1/threads/:threadId/sandbox/terminal/context", diff --git a/apps/gateway-worker/src/core-http-routes.ts b/apps/gateway-worker/src/core-http-routes.ts index 692209a1..d1e01d5a 100644 --- a/apps/gateway-worker/src/core-http-routes.ts +++ b/apps/gateway-worker/src/core-http-routes.ts @@ -3,7 +3,6 @@ import type { UserId } from "@cheatcode/types"; import { agentServiceRequest } from "./agent-forwarding"; import { authenticate } from "./authenticate"; import type { GatewayApp, GatewayEnv } from "./gateway-env"; -import { OPENAPI_DOCUMENT, openApiDocsHtml } from "./openapi"; import { rateLimit, rateLimitPublic, withRateLimitHeaders } from "./rate-limit"; import { readDownstreamReleaseHealth } from "./release-health"; import { listUserSkillsRoute } from "./skills-routes"; @@ -12,7 +11,6 @@ import type { WaitUntilContext } from "./wait-until-context"; export function registerCoreHttpRoutes(app: GatewayApp): void { registerHealthRoute(app); - registerDiscoveryRoutes(app); registerTelemetryRoutes(app); registerOutputRoute(app); registerSkillRoutes(app); @@ -49,22 +47,6 @@ function registerHealthRoute(app: GatewayApp): void { }); } -function registerDiscoveryRoutes(app: GatewayApp): void { - app.get("/openapi.json", async (c) => { - const headers = await rateLimitPublic(c, "GET /openapi.json", "publicRead"); - return withRateLimitHeaders( - new Response(JSON.stringify(OPENAPI_DOCUMENT), { - headers: { "Content-Type": "application/json; charset=utf-8" }, - }), - headers, - ); - }); - app.get("/docs", async (c) => { - await rateLimitPublic(c, "GET /docs", "publicRead"); - return c.html(openApiDocsHtml()); - }); -} - function registerTelemetryRoutes(app: GatewayApp): void { app.post("/v1/client-error", async (c) => { await rateLimitPublic(c, "POST /v1/client-error", "publicWrite"); diff --git a/apps/gateway-worker/src/durable-objects/quota-tracker-contract.ts b/apps/gateway-worker/src/durable-objects/quota-tracker-contract.ts deleted file mode 100644 index b15a9778..00000000 --- a/apps/gateway-worker/src/durable-objects/quota-tracker-contract.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { QuotaFeatureSchema, QuotaLimitSchema, QuotaPeriodEndSchema } from "@cheatcode/types/quota"; -import { z } from "zod"; - -export const QuotaHistoryBodySchema = z - .object({ feature: QuotaFeatureSchema, from: z.string().datetime() }) - .strict(); - -export const QuotaSnapshotBodySchema = z - .object({ - periodEnd: QuotaPeriodEndSchema, - }) - .strict(); - -export const QuotaSnapshotResultSchema = z.partialRecord( - QuotaFeatureSchema, - z - .object({ - limit: QuotaLimitSchema, - used: z.number().finite().nonnegative(), - }) - .strict(), -); - -export const QuotaHistoryResultSchema = z.array( - z.object({ amount: z.number().positive(), recordedAt: z.number().int().nonnegative() }).strict(), -); - -export type QuotaHistoryResult = z.infer; -export type QuotaSnapshotResult = z.infer; diff --git a/apps/gateway-worker/src/durable-objects/quota-tracker.ts b/apps/gateway-worker/src/durable-objects/quota-tracker.ts index 1ed518d3..bd2d3454 100644 --- a/apps/gateway-worker/src/durable-objects/quota-tracker.ts +++ b/apps/gateway-worker/src/durable-objects/quota-tracker.ts @@ -1,27 +1,16 @@ import { DurableObject } from "cloudflare:workers"; -import { readJsonRequest } from "@cheatcode/observability"; import { - QUOTA_TRACKER_MAX_REQUEST_BYTES, type QuotaFeature, QuotaFeatureSchema, - QuotaPeekRequestSchema, - QuotaRecordRequestSchema, - QuotaSetLimitRequestSchema, - QuotaSetLimitResponseSchema, - QuotaTryConsumeRequestSchema, + type QuotaHistoryResult, + QuotaHistoryResultSchema, + type QuotaSnapshotResult, + QuotaSnapshotResultSchema, type QuotaTryConsumeResponse, QuotaTryConsumeResponseSchema, type QuotaUsageResponse, QuotaUsageResponseSchema, } from "@cheatcode/types/quota"; -import { - QuotaHistoryBodySchema, - type QuotaHistoryResult, - QuotaHistoryResultSchema, - QuotaSnapshotBodySchema, - type QuotaSnapshotResult, - QuotaSnapshotResultSchema, -} from "./quota-tracker-contract"; import { ensureQuotaTrackerStorage, hasQuotaTrackerStorage } from "./quota-tracker-storage"; import { nextGatewayDurableObjectAlarm, QUOTA_TRACKER_RETENTION_MS } from "./retention"; @@ -220,62 +209,6 @@ export class QuotaTracker extends DurableObject { return QuotaSnapshotResultSchema.parse(snapshot); } - public override async fetch(request: Request): Promise { - if (request.method !== "POST") { - return new Response("Method not allowed", { status: 405 }); - } - return this.handlePost(new URL(request.url).pathname, request); - } - - private async handlePost(pathname: string, request: Request): Promise { - if (pathname === "/try-consume") { - const body = QuotaTryConsumeRequestSchema.parse(await readQuotaRequest(request)); - return Response.json( - QuotaTryConsumeResponseSchema.parse( - await this.tryConsume(body.feature, body.amount, new Date(body.periodEnd), body.eventId), - ), - ); - } - if (pathname === "/peek") { - const body = QuotaPeekRequestSchema.parse(await readQuotaRequest(request)); - return Response.json( - QuotaUsageResponseSchema.parse(await this.peek(body.feature, new Date(body.periodEnd))), - ); - } - if (pathname === "/record") { - const body = QuotaRecordRequestSchema.parse(await readQuotaRequest(request)); - return Response.json( - QuotaUsageResponseSchema.parse( - await this.record( - body.feature, - body.amount, - new Date(body.periodEnd), - body.eventId, - new Date(body.recordedAt), - ), - ), - ); - } - if (pathname === "/history") { - const body = QuotaHistoryBodySchema.parse(await readQuotaRequest(request)); - return Response.json(await this.history(body.feature, new Date(body.from))); - } - if (pathname === "/set-limit") { - const body = QuotaSetLimitRequestSchema.parse(await readQuotaRequest(request)); - await this.setLimit(body.feature, body.limit, body.entitlementVersion); - return Response.json(QuotaSetLimitResponseSchema.parse({ ok: true })); - } - if (pathname === "/snapshot") { - const body = QuotaSnapshotBodySchema.parse(await readQuotaRequest(request)); - return Response.json(await this.snapshot(new Date(body.periodEnd))); - } - if (pathname === "/delete-all") { - await this.deleteAllState(); - return Response.json({ ok: true }); - } - return new Response("Not found", { status: 404 }); - } - public override async alarm(): Promise { if (!hasQuotaTrackerStorage(this.ctx)) { await this.ctx.storage.deleteAlarm(); @@ -442,10 +375,6 @@ export class QuotaTracker extends DurableObject { } } -async function readQuotaRequest(request: Request): Promise { - return readJsonRequest(request, QUOTA_TRACKER_MAX_REQUEST_BYTES, "Quota tracker request"); -} - function operationConsumeResult(row: OperationRow): QuotaTryConsumeResponse { return QuotaTryConsumeResponseSchema.parse({ allowed: row.allowed === 1, diff --git a/apps/gateway-worker/src/durable-objects/rate-limit-contract.ts b/apps/gateway-worker/src/durable-objects/rate-limit-contract.ts index 4fe003c9..94b516af 100644 --- a/apps/gateway-worker/src/durable-objects/rate-limit-contract.ts +++ b/apps/gateway-worker/src/durable-objects/rate-limit-contract.ts @@ -1,27 +1,10 @@ -import { z } from "zod"; - -const RateLimitConfigSchema = z - .object({ - capacity: z.number().int().positive(), - refillPerSec: z.number().finite().positive(), - }) - .strict(); - -export const RateLimitConsumeBodySchema = z - .object({ - key: z.string().min(1).max(256), - cost: z.number().int().positive(), - config: RateLimitConfigSchema, - }) - .strict(); - -export const RateLimitResultSchema = z - .object({ - allowed: z.boolean(), - remaining: z.number().int().nonnegative(), - retryAfterMs: z.number().int().nonnegative(), - }) - .strict(); - -export type RateLimitConfig = z.infer; -export type RateLimitResult = z.infer; +export interface RateLimitConfig { + capacity: number; + refillPerSec: number; +} + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + retryAfterMs: number; +} diff --git a/apps/gateway-worker/src/durable-objects/rate-limiter.ts b/apps/gateway-worker/src/durable-objects/rate-limiter.ts index ca7f66bc..e90c37a9 100644 --- a/apps/gateway-worker/src/durable-objects/rate-limiter.ts +++ b/apps/gateway-worker/src/durable-objects/rate-limiter.ts @@ -1,10 +1,5 @@ import { DurableObject } from "cloudflare:workers"; -import { readJsonRequest } from "@cheatcode/observability"; -import { - type RateLimitConfig, - RateLimitConsumeBodySchema, - type RateLimitResult, -} from "./rate-limit-contract"; +import type { RateLimitConfig, RateLimitResult } from "./rate-limit-contract"; import { ensureRateLimiterStorage, hasRateLimiterStorage } from "./rate-limiter-storage"; import { nextGatewayDurableObjectAlarm, RATE_LIMITER_RETENTION_MS } from "./retention"; @@ -13,8 +8,6 @@ interface BucketRow { last_refill_ms: number; } -const MAX_RATE_LIMIT_REQUEST_BYTES = 16 * 1024; - function isBucketRow(value: unknown): value is BucketRow { if (!value || typeof value !== "object") { return false; @@ -67,16 +60,6 @@ export class RateLimiter extends DurableObject { return { allowed: true, remaining: Math.floor(nextTokens), retryAfterMs: 0 }; } - public override async fetch(request: Request): Promise { - if (request.method !== "POST") { - return new Response("Method not allowed", { status: 405 }); - } - const body = RateLimitConsumeBodySchema.parse( - await readJsonRequest(request, MAX_RATE_LIMIT_REQUEST_BYTES, "Rate limit request"), - ); - return Response.json(await this.consume(body.key, body.cost, body.config)); - } - public override async alarm(): Promise { if (!hasRateLimiterStorage(this.ctx)) { await this.ctx.storage.deleteAlarm(); diff --git a/apps/gateway-worker/src/error-handling.ts b/apps/gateway-worker/src/error-handling.ts index cc25f9f0..1d15ba58 100644 --- a/apps/gateway-worker/src/error-handling.ts +++ b/apps/gateway-worker/src/error-handling.ts @@ -1,10 +1,5 @@ -import { createLogger, safeErrorTelemetry, toAPIError } from "@cheatcode/observability"; +import { toAPIError } from "@cheatcode/observability"; export function formatGatewayRouteError(error: unknown, requestId: string): Response { - const apiError = toAPIError(error); - createLogger({ requestId }).error("gateway_request_failed", { - apiCode: apiError.code, - ...safeErrorTelemetry(error), - }); - return apiError.toResponse(requestId); + return toAPIError(error).toResponse(requestId); } diff --git a/apps/gateway-worker/src/index.ts b/apps/gateway-worker/src/index.ts index 7e3da816..2de69d2d 100644 --- a/apps/gateway-worker/src/index.ts +++ b/apps/gateway-worker/src/index.ts @@ -1,13 +1,12 @@ import { GatewayWorkerEnvSchema } from "@cheatcode/env"; import { APIError, - createLogger, - emitErrorEvent, - emitPerformanceMetric, - safeErrorTelemetry, - toAPIError, - withErrorHandler, + createPerformanceMetricMiddleware, + createWorkerRuntime, + reportWorkerError, + requestId, } from "@cheatcode/observability"; +import { normalizeTelemetryPath } from "@cheatcode/types"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { routePath } from "hono/route"; @@ -24,12 +23,6 @@ import { formatGatewayRouteError } from "./error-handling"; import type { GatewayContext, GatewayEnv } from "./gateway-env"; import { registerIntegrationHttpRoutes } from "./integration-http-routes"; import { resolveLocalPreviewRoute } from "./local-preview-routing"; -import { - assertOpenApiRouteParity, - gatewayOperationIdForRegisteredRoute, - gatewayOperationIdForRequest, - UNMATCHED_GATEWAY_ROUTE, -} from "./openapi-route-parity"; import { registerProjectHttpRoutes } from "./project-http-routes"; import { registerProviderHttpRoutes } from "./provider-http-routes"; import { withRateLimitErrorHeaders } from "./rate-limit"; @@ -75,16 +68,18 @@ const GATEWAY_SECURITY_HEADERS = { const gatewayApp = new Hono<{ Bindings: GatewayEnv }>(); -gatewayApp.onError((error, c) => { - const id = c.req.header("X-Request-Id") ?? requestId(); - const apiError = toAPIError(error); - emitErrorEvent(c.env, { - errorCategory: "gateway", - errorCode: apiError.code, - httpStatus: apiError.status, - route: routeNameForContext(c), +// Answer route errors in-band: cors() stages its headers on the context before +// the route runs and secureHeaders() applies after it, so the error response +// must flow back through the middleware stack. Rethrowing to the runtime's +// catch-all would strip CORS from every cross-origin error the SPA reads. +gatewayApp.onError((error, context) => { + const id = context.req.header("X-Request-Id") ?? requestId(); + reportWorkerError(context.env, { + error, + errorLogName: "gateway_request_failed", + requestId: id, + route: routeNameForContext(context), workerName: "gateway", - ...safeErrorTelemetry(error), }); return withRateLimitErrorHeaders(formatGatewayRouteError(error, id), error); }); @@ -104,21 +99,13 @@ gatewayApp.use( ), }), ); -gatewayApp.use("*", async (c, next) => { - const startedAt = performance.now(); - let status = 500; - try { - await next(); - status = c.res.status; - } finally { - emitPerformanceMetric(c.env, { - route: routeNameForContext(c), - statusClass: statusClass(status), - totalMs: performance.now() - startedAt, - workerName: "gateway", - }); - } -}); +gatewayApp.use( + "*", + createPerformanceMetricMiddleware({ + routeName: routeNameForContext, + workerName: "gateway", + }), +); registerCoreHttpRoutes(gatewayApp); registerAccountHttpRoutes(gatewayApp); @@ -127,80 +114,42 @@ registerProviderHttpRoutes(gatewayApp); registerIntegrationHttpRoutes(gatewayApp); registerBillingHttpRoutes(gatewayApp); registerAgentHttpRoutes(gatewayApp); -assertOpenApiRouteParity(gatewayApp.routes); - -function requestId(): string { - return `req_${crypto.randomUUID().replaceAll("-", "")}`; -} - -function withRequestId(response: Response, id: string): Response { - if (response.status === 101 || response.webSocket) { - return response; - } - const wrapped = new Response(response.body, response); - wrapped.headers.set("X-Request-Id", id); - return wrapped; -} function routeName(request: Request): string { - return gatewayOperationIdForRequest(request); + const url = new URL(request.url); + return `${request.method} ${normalizeTelemetryPath(url.pathname)}`; } function routeNameForContext(c: GatewayContext): string { try { - return gatewayOperationIdForRegisteredRoute(c.req.method, routePath(c, -1)); + return `${c.req.method} ${routePath(c, -1)}`; } catch { - return UNMATCHED_GATEWAY_ROUTE; + return routeName(c.req.raw); } } -function statusClass(status: number): string { - if (status >= 500) return "5xx"; - if (status >= 400) return "4xx"; - if (status >= 300) return "3xx"; - return "2xx"; -} - -const gatewayHandler = { - async fetch(request: Request, env: GatewayEnv, ctx: ExecutionContext): Promise { - const id = requestId(); - const logger = createLogger({ requestId: id }); - try { - GatewayWorkerEnvSchema.parse(env); - return await routeGatewayRequest(request, env, ctx, id); - } catch (error) { - const apiError = toAPIError(error); - emitErrorEvent(env, { - errorCategory: "gateway", - errorCode: apiError.code, - httpStatus: apiError.status, - route: routeName(request), - workerName: "gateway", - ...safeErrorTelemetry(error), - }); - logger.error("gateway_request_failed", { - apiCode: apiError.code, - ...safeErrorTelemetry(error), - }); - return apiError.toResponse(id); - } +const gatewayHandler = createWorkerRuntime({ + errorCategory: "gateway", + errorLogName: "gateway_request_failed", + fetch: async (request, env, ctx) => { + GatewayWorkerEnvSchema.parse(env); + return routeGatewayRequest(request, env, ctx); }, -}; + formatError: ({ error, requestId: id }) => + withRateLimitErrorHeaders(formatGatewayRouteError(error, id), error), + routeName, + workerName: "gateway", +}); async function routeGatewayRequest( request: Request, env: GatewayEnv, ctx: ExecutionContext, - id: string, ): Promise { - const requestWithId = isWebSocketUpgrade(request) ? request : new Request(request); - if (!isWebSocketUpgrade(requestWithId)) { - requestWithId.headers.set("X-Request-Id", id); - } const localPreview = - env.CHEATCODE_ENVIRONMENT === "development" ? resolveLocalPreviewRoute(requestWithId) : null; + env.CHEATCODE_ENVIRONMENT === "development" ? resolveLocalPreviewRoute(request) : null; if (localPreview?.kind === "redirect") { - return withRequestId(localPreview.response, id); + return localPreview.response; } if (localPreview?.kind === "proxy") { if (!env.PREVIEW_PROXY) { @@ -208,18 +157,9 @@ async function routeGatewayRequest( retriable: false, }); } - return withRequestId(await env.PREVIEW_PROXY.fetch(localPreview.request), id); + return env.PREVIEW_PROXY.fetch(localPreview.request); } - return withRequestId(await gatewayApp.fetch(requestWithId, env, ctx), id); -} - -function isWebSocketUpgrade(request: Request): boolean { - return (request.headers.get("Upgrade") ?? "").toLowerCase() === "websocket"; + return gatewayApp.fetch(request, env, ctx); } -export default withErrorHandler(gatewayHandler, { - errorCategory: "gateway", - requestId: (request) => request.headers.get("X-Request-Id"), - routeName, - workerName: "gateway", -}); +export default gatewayHandler; diff --git a/apps/gateway-worker/src/limits.ts b/apps/gateway-worker/src/limits.ts index 9693ce19..38aa5d2e 100644 --- a/apps/gateway-worker/src/limits.ts +++ b/apps/gateway-worker/src/limits.ts @@ -11,15 +11,9 @@ import { lockUserProjectMutations, withUserContext, } from "@cheatcode/db"; -import { APIError, createLogger, readBoundedResponseJson } from "@cheatcode/observability"; +import { APIError, createLogger } from "@cheatcode/observability"; import type { UserId } from "@cheatcode/types"; -import { - QUOTA_FEATURES, - QUOTA_TRACKER_MAX_RESPONSE_BYTES, - type QuotaFeature, - QuotaSetLimitRequestSchema, - QuotaSetLimitResponseSchema, -} from "@cheatcode/types/quota"; +import { QUOTA_FEATURES, type QuotaFeature } from "@cheatcode/types/quota"; import type { QuotaTracker } from "./durable-objects/quota-tracker"; export interface LimitBindings { @@ -108,21 +102,15 @@ async function setQuotaLimit( limit: number, entitlementVersion: number, ): Promise { - const body = QuotaSetLimitRequestSchema.parse({ entitlementVersion, feature, limit }); - const response = await stub.fetch("https://quota.internal/set-limit", { - body: JSON.stringify(body), - method: "POST", - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); + try { + await stub.setLimit(feature, limit, entitlementVersion); + } catch (error) { throw new APIError(503, "unavailable_maintenance", "Quota tracker is unavailable", { + cause: error, hint: "Retry the request. If it persists, check the QuotaTracker Durable Object logs.", retriable: true, }); } - QuotaSetLimitResponseSchema.parse( - await readBoundedResponseJson(response, QUOTA_TRACKER_MAX_RESPONSE_BYTES, "Quota set-limit"), - ); } async function readCachedEntitlement( diff --git a/apps/gateway-worker/src/metadata-routes.ts b/apps/gateway-worker/src/metadata-routes.ts deleted file mode 100644 index 23fc8ae1..00000000 --- a/apps/gateway-worker/src/metadata-routes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - AGENT_CAPABILITIES, - AgentSummarySchema, - TOOL_CAPABILITIES, - type ToolDomain, - ToolSummarySchema, -} from "@cheatcode/types"; - -export function listToolsRoute(domain: ToolDomain | undefined): Response { - const tools = domain - ? TOOL_CAPABILITIES.filter((capability) => capability.domain === domain) - : TOOL_CAPABILITIES; - return Response.json(ToolSummarySchema.array().parse(tools)); -} - -export function listAgentsRoute(): Response { - return Response.json(AgentSummarySchema.array().parse(AGENT_CAPABILITIES)); -} diff --git a/apps/gateway-worker/src/openapi-account-routes.ts b/apps/gateway-worker/src/openapi-account-routes.ts deleted file mode 100644 index be0af056..00000000 --- a/apps/gateway-worker/src/openapi-account-routes.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - ActivityHistoryResponseSchema, - UpdateUserProfileSchema, - UserProfileSchema, -} from "@cheatcode/types"; -import { - type JsonValue, - jsonBody, - jsonResponse, - type OpenApiRoute, - schemaRef, -} from "./openapi-builder"; -import { withJsonSchemaConstraints, zodJsonSchema } from "./openapi-zod"; - -export const accountSchemas: Record = { - ActivityHistory: zodJsonSchema(ActivityHistoryResponseSchema), - UpdateUserProfile: withJsonSchemaConstraints(zodJsonSchema(UpdateUserProfileSchema, "input"), { - minProperties: 1, - }), - UserProfile: zodJsonSchema(UserProfileSchema), -}; - -const activityDaysParameter: JsonValue = { - in: "query", - name: "days", - schema: { default: 30, maximum: 366, minimum: 1, type: "integer" }, -}; - -export const accountRoutes: OpenApiRoute[] = [ - { - method: "get", - operationId: "getMyProfile", - path: "/v1/me/profile", - responses: { "200": jsonResponse("User profile", schemaRef("UserProfile")) }, - security: [{ bearerAuth: [] }], - summary: "Get current user profile", - tags: ["account"], - }, - { - method: "patch", - operationId: "updateMyProfile", - path: "/v1/me/profile", - requestBody: jsonBody(schemaRef("UpdateUserProfile")), - responses: { - "200": jsonResponse("Updated user profile", schemaRef("UserProfile")), - "400": jsonResponse("Invalid request body", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Update current user profile", - tags: ["account"], - }, - { - method: "get", - operationId: "getActivityHistory", - parameters: [activityDaysParameter], - path: "/v1/activity", - responses: { "200": jsonResponse("Recent activity", schemaRef("ActivityHistory")) }, - security: [{ bearerAuth: [] }], - summary: "Get recent run and sandbox activity", - tags: ["account"], - }, -]; diff --git a/apps/gateway-worker/src/openapi-billing-routes.ts b/apps/gateway-worker/src/openapi-billing-routes.ts deleted file mode 100644 index 7f883c59..00000000 --- a/apps/gateway-worker/src/openapi-billing-routes.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - BillingCancelSchema, - BillingCatalogResponseSchema, - BillingCheckoutSchema, - BillingStateResponseSchema, - BillingSubscriptionActionResponseSchema, - BillingUrlResponseSchema, - SandboxUsageSummaryResponseSchema, -} from "@cheatcode/types"; -import { type JsonValue, jsonBody, jsonResponse, type OpenApiRoute } from "./openapi-builder"; -import { zodJsonSchema } from "./openapi-zod"; - -const objectSchema = (name: string): JsonValue => ({ $ref: `#/components/schemas/${name}` }); - -export const billingSchemas: Record = { - BillingCancel: zodJsonSchema(BillingCancelSchema, "input"), - BillingCatalog: zodJsonSchema(BillingCatalogResponseSchema), - BillingCheckout: zodJsonSchema(BillingCheckoutSchema, "input"), - BillingState: zodJsonSchema(BillingStateResponseSchema), - BillingSubscriptionAction: zodJsonSchema(BillingSubscriptionActionResponseSchema), - BillingUrl: zodJsonSchema(BillingUrlResponseSchema), - SandboxUsageSummary: zodJsonSchema(SandboxUsageSummaryResponseSchema), -}; - -export const billingRoutes: OpenApiRoute[] = [ - { - method: "get", - operationId: "getMyUsage", - path: "/v1/me/usage", - responses: { - "200": jsonResponse("Sandbox-hours usage summary", objectSchema("SandboxUsageSummary")), - }, - security: [{ bearerAuth: [] }], - summary: "Get sandbox-hours usage summary", - tags: ["billing"], - }, - { - method: "get", - operationId: "getBillingCatalog", - path: "/v1/billing/catalog", - responses: { "200": jsonResponse("Plan catalog", objectSchema("BillingCatalog")) }, - security: [{ bearerAuth: [] }], - summary: "Get plan catalog", - tags: ["billing"], - }, - { - method: "post", - operationId: "createBillingPortal", - path: "/v1/billing/portal", - responses: { "200": jsonResponse("Portal URL", objectSchema("BillingUrl")) }, - security: [{ bearerAuth: [] }], - summary: "Create Polar portal URL", - tags: ["billing"], - }, - { - method: "get", - operationId: "getBillingState", - path: "/v1/billing/state", - responses: { "200": jsonResponse("Billing state", objectSchema("BillingState")) }, - security: [{ bearerAuth: [] }], - summary: "Get billing subscription state", - tags: ["billing"], - }, - { - method: "post", - operationId: "createBillingCheckout", - path: "/v1/billing/checkout", - requestBody: jsonBody(objectSchema("BillingCheckout")), - responses: { "200": jsonResponse("Checkout URL", objectSchema("BillingUrl")) }, - security: [{ bearerAuth: [] }], - summary: "Create Polar checkout URL", - tags: ["billing"], - }, - { - method: "post", - operationId: "cancelBillingSubscription", - path: "/v1/billing/cancel", - requestBody: jsonBody(objectSchema("BillingCancel"), false), - responses: { - "200": jsonResponse( - "Subscription cancellation state", - objectSchema("BillingSubscriptionAction"), - ), - }, - security: [{ bearerAuth: [] }], - summary: "Cancel Polar subscription at period end", - tags: ["billing"], - }, - { - method: "post", - operationId: "reactivateBillingSubscription", - path: "/v1/billing/reactivate", - responses: { - "200": jsonResponse( - "Subscription reactivation state", - objectSchema("BillingSubscriptionAction"), - ), - }, - security: [{ bearerAuth: [] }], - summary: "Reactivate a pending-cancel subscription", - tags: ["billing"], - }, -]; diff --git a/apps/gateway-worker/src/openapi-builder.ts b/apps/gateway-worker/src/openapi-builder.ts deleted file mode 100644 index f954d466..00000000 --- a/apps/gateway-worker/src/openapi-builder.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { - INTEGRATION_NAME_MAX_LENGTH, - INTEGRATION_NAME_PATTERN, -} from "@cheatcode/types/integrations"; - -type JsonPrimitive = boolean | null | number | string; -export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; - -export interface OpenApiRoute { - method: "delete" | "get" | "patch" | "post"; - operationId: string; - parameters?: JsonValue[]; - path: string; - rateLimited?: boolean; - requestBody?: JsonValue; - responses: Record; - security?: JsonValue[]; - summary: string; - tags: string[]; -} - -export const schemaRef = (name: string): JsonValue => ({ $ref: `#/components/schemas/${name}` }); - -export const jsonResponse = (description: string, schema: JsonValue): JsonValue => ({ - content: { "application/json": { schema } }, - description, -}); - -export const emptyResponse = (description: string): JsonValue => ({ description }); - -export function contentResponse( - description: string, - mediaType: string, - schema: JsonValue, -): JsonValue { - return { content: { [mediaType]: { schema } }, description }; -} - -export function jsonBody(schema: JsonValue, required = true): JsonValue { - return { - content: { - "application/json": { schema }, - }, - required, - }; -} - -function buildPaths(sourceRoutes: OpenApiRoute[]): { [path: string]: JsonValue } { - assertUniqueRoutes(sourceRoutes); - const paths: { [path: string]: { [method: string]: JsonValue } } = {}; - for (const route of sourceRoutes) { - const current = paths[route.path] ?? {}; - const parameters = routeParameters(route); - current[route.method] = { - operationId: route.operationId, - ...(parameters ? { parameters } : {}), - ...(route.requestBody ? { requestBody: route.requestBody } : {}), - responses: routeResponses(route), - ...(route.security ? { security: route.security } : {}), - summary: route.summary, - tags: route.tags, - }; - paths[route.path] = current; - } - return paths; -} - -export function buildOpenApiDocument(options: { - routes: OpenApiRoute[]; - schemas: { [name: string]: JsonValue }; -}): JsonValue { - const document: JsonValue = { - components: { - schemas: options.schemas, - securitySchemes: { - bearerAuth: { bearerFormat: "JWT", scheme: "bearer", type: "http" }, - }, - }, - info: { - description: "Cheatcode V2 public product API. Webhooks are served separately.", - title: "Cheatcode Gateway API", - version: "1.0.0", - }, - openapi: "3.1.0", - paths: buildPaths(options.routes), - servers: [{ url: "https://gateway.trycheatcode.com" }, { url: "http://localhost:8787" }], - tags: [ - { name: "account" }, - { name: "discovery" }, - { name: "projects" }, - { name: "threads" }, - { name: "runs" }, - { name: "outputs" }, - { name: "metadata" }, - { name: "sandbox" }, - { name: "skills" }, - { name: "integrations" }, - { name: "byok" }, - { name: "billing" }, - { name: "telemetry" }, - { name: "system" }, - ], - }; - assertComponentReferences(document, options.schemas); - return document; -} - -export function renderOpenApiDocsHtml(routes: OpenApiRoute[]): string { - return ` - - - - - Cheatcode API Docs - - - -
-

Cheatcode Gateway API

-

OpenAPI JSON is available at /openapi.json.

- - - ${routes - .map( - (route) => - ``, - ) - .join("")} -
MethodPathOperation
${route.method.toUpperCase()}${route.path}${route.operationId}
-
- -`; -} - -function routeParameters(route: OpenApiRoute): JsonValue[] | undefined { - const parameters = [...pathParameters(route.path), ...(route.parameters ?? [])]; - return parameters.length > 0 ? parameters : undefined; -} - -function pathParameters(path: string): JsonValue[] { - const names = new Set(); - for (const match of path.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)) { - const name = match[1]; - if (name) { - names.add(name); - } - } - return [...names].map((name) => ({ - in: "path", - name, - required: true, - schema: pathParameterSchema(name), - })); -} - -function routeResponses(route: OpenApiRoute): Record { - return { - ...(route.security?.length - ? { "401": jsonResponse("Authentication required", schemaRef("Error")) } - : {}), - ...(route.security?.length || route.rateLimited ? { "429": rateLimitErrorResponse() } : {}), - ...route.responses, - }; -} - -function rateLimitErrorResponse(): JsonValue { - return { - content: { "application/json": { schema: schemaRef("Error") } }, - description: "Rate limit exceeded", - headers: { - "RateLimit-Limit": { schema: { minimum: 0, type: "integer" } }, - "RateLimit-Remaining": { schema: { minimum: 0, type: "integer" } }, - "RateLimit-Reset": { schema: { minimum: 0, type: "integer" } }, - "Retry-After": { schema: { minimum: 1, type: "integer" } }, - }, - }; -} - -function assertUniqueRoutes(routes: OpenApiRoute[]): void { - const routeKeys = new Set(); - const operationIds = new Set(); - for (const route of routes) { - const routeKey = `${route.method.toUpperCase()} ${route.path}`; - if (routeKeys.has(routeKey)) { - throw new Error(`Duplicate OpenAPI route: ${routeKey}`); - } - routeKeys.add(routeKey); - if (operationIds.has(route.operationId)) { - throw new Error(`Duplicate OpenAPI operationId: ${route.operationId}`); - } - operationIds.add(route.operationId); - } -} - -function assertComponentReferences( - value: JsonValue, - componentSchemas: { [name: string]: JsonValue }, -): void { - if (Array.isArray(value)) { - for (const item of value) { - assertComponentReferences(item, componentSchemas); - } - return; - } - if (typeof value !== "object" || value === null) { - return; - } - const reference = value["$ref"]; - if (typeof reference === "string" && reference.startsWith(COMPONENT_SCHEMA_PREFIX)) { - const componentName = reference.slice(COMPONENT_SCHEMA_PREFIX.length); - if (!Object.hasOwn(componentSchemas, componentName)) { - throw new Error(`Missing OpenAPI component schema: ${componentName}`); - } - } - for (const child of Object.values(value)) { - assertComponentReferences(child, componentSchemas); - } -} - -function pathParameterSchema(name: string): JsonValue { - if (UUID_PATH_PARAMETERS.has(name)) { - return { format: "uuid", type: "string" }; - } - if (name === "name") { - return { - maxLength: INTEGRATION_NAME_MAX_LENGTH, - minLength: 1, - pattern: INTEGRATION_NAME_PATTERN.source, - type: "string", - }; - } - if (name === "connectionId") { - return { maxLength: 256, minLength: 1, type: "string" }; - } - if (name === "provider") { - return { - enum: ["anthropic", "openai", "google", "openrouter", "deepseek", "exa", "firecrawl"], - type: "string", - }; - } - return { type: "string" }; -} - -const UUID_PATH_PARAMETERS = new Set(["outputId", "projectId", "runId", "skillId", "threadId"]); - -const COMPONENT_SCHEMA_PREFIX = "#/components/schemas/"; diff --git a/apps/gateway-worker/src/openapi-discovery-routes.ts b/apps/gateway-worker/src/openapi-discovery-routes.ts deleted file mode 100644 index d442e2ef..00000000 --- a/apps/gateway-worker/src/openapi-discovery-routes.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - GreetingResponseSchema, - RecentThreadsResponseSchema, - SearchResponseSchema, -} from "@cheatcode/types"; -import { type JsonValue, jsonResponse, type OpenApiRoute, schemaRef } from "./openapi-builder"; -import { zodJsonSchema } from "./openapi-zod"; - -const searchQueryParameter: JsonValue = { - in: "query", - name: "q", - required: true, - schema: { maxLength: 100, minLength: 1, type: "string" }, -}; - -const searchLimitParameter: JsonValue = { - in: "query", - name: "limit", - schema: { default: 10, maximum: 20, minimum: 1, type: "integer" }, -}; - -const recentThreadsLimitParameter: JsonValue = { - in: "query", - name: "limit", - schema: { default: 20, maximum: 50, minimum: 1, type: "integer" }, -}; - -export const discoverySchemas: Record = { - GreetingResponse: zodJsonSchema(GreetingResponseSchema), - RecentThreadsResponse: zodJsonSchema(RecentThreadsResponseSchema), - SearchResponse: zodJsonSchema(SearchResponseSchema), -}; - -export const discoveryRoutes: OpenApiRoute[] = [ - { - method: "get", - operationId: "searchWorkspace", - parameters: [searchQueryParameter, searchLimitParameter], - path: "/v1/search", - responses: { - "200": jsonResponse("Workspace search results", schemaRef("SearchResponse")), - "400": jsonResponse("Invalid query", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Search projects and threads", - tags: ["discovery"], - }, - { - method: "get", - operationId: "listRecentThreads", - parameters: [recentThreadsLimitParameter], - path: "/v1/threads", - responses: { - "200": jsonResponse("Recent chats (threads)", schemaRef("RecentThreadsResponse")), - "400": jsonResponse("Invalid query", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "List recent chats across projects", - tags: ["discovery"], - }, - { - method: "get", - operationId: "getGreeting", - path: "/v1/greeting", - responses: { "200": jsonResponse("Greeting geo and weather", schemaRef("GreetingResponse")) }, - security: [{ bearerAuth: [] }], - summary: "Get greeting geo and current weather", - tags: ["discovery"], - }, -]; diff --git a/apps/gateway-worker/src/openapi-integration-schemas.ts b/apps/gateway-worker/src/openapi-integration-schemas.ts deleted file mode 100644 index 4a5aecdb..00000000 --- a/apps/gateway-worker/src/openapi-integration-schemas.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - IntegrationCatalogSchema, - IntegrationConnectResponseSchema, - IntegrationSchema, - ProviderKeySummarySchema, - ToolkitActionsResponseSchema, - UpsertProviderKeySchema, -} from "@cheatcode/types"; -import type { JsonValue } from "./openapi-builder"; -import { zodJsonSchema } from "./openapi-zod"; - -export const integrationSchemas: Record = { - Integration: zodJsonSchema(IntegrationSchema), - IntegrationCatalog: zodJsonSchema(IntegrationCatalogSchema), - IntegrationConnect: zodJsonSchema(IntegrationConnectResponseSchema), - ProviderKeySummary: zodJsonSchema(ProviderKeySummarySchema), - ToolkitActionsResponse: zodJsonSchema(ToolkitActionsResponseSchema), - UpsertProviderKey: zodJsonSchema(UpsertProviderKeySchema, "input"), -}; diff --git a/apps/gateway-worker/src/openapi-project-routes.ts b/apps/gateway-worker/src/openapi-project-routes.ts deleted file mode 100644 index d02272e5..00000000 --- a/apps/gateway-worker/src/openapi-project-routes.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - CreateProjectSchema, - CreateThreadSchema, - ProjectFileListSchema, - ProjectFileUploadResponseSchema, - ProjectSummarySchema, - ThreadSchema, - UIMessageRecordSchema, - UpdateProjectSchema, - UpdateThreadSchema, -} from "@cheatcode/types"; -import { - contentResponse, - emptyResponse, - type JsonValue, - jsonBody, - jsonResponse, - type OpenApiRoute, - schemaRef, -} from "./openapi-builder"; -import { paginatedSchemaFor, paginationParameters } from "./openapi-schema-utils"; -import { withJsonSchemaConstraints, zodJsonSchema } from "./openapi-zod"; - -export const projectSchemas: Record = { - CreateProject: zodJsonSchema(CreateProjectSchema, "input"), - CreateThread: zodJsonSchema(CreateThreadSchema, "input"), - Project: zodJsonSchema(ProjectSummarySchema), - ProjectFileList: zodJsonSchema(ProjectFileListSchema), - ProjectFileUploadResponse: zodJsonSchema(ProjectFileUploadResponseSchema), - Thread: zodJsonSchema(ThreadSchema), - UIMessage: zodJsonSchema(UIMessageRecordSchema), - UpdateProject: withJsonSchemaConstraints(zodJsonSchema(UpdateProjectSchema, "input"), { - minProperties: 1, - }), - UpdateThread: zodJsonSchema(UpdateThreadSchema, "input"), -}; - -const paginatedSchema = (name: string): JsonValue => paginatedSchemaFor(projectSchemas, name); - -export const projectRoutes: OpenApiRoute[] = [ - { - method: "get", - operationId: "listProjects", - parameters: paginationParameters, - path: "/v1/projects", - responses: { "200": jsonResponse("Projects", paginatedSchema("Project")) }, - security: [{ bearerAuth: [] }], - summary: "List projects", - tags: ["projects"], - }, - { - method: "post", - operationId: "createProject", - path: "/v1/projects", - requestBody: jsonBody(schemaRef("CreateProject")), - responses: { "201": jsonResponse("Created project", schemaRef("Project")) }, - security: [{ bearerAuth: [] }], - summary: "Create a project", - tags: ["projects"], - }, - { - method: "get", - operationId: "listProjectFiles", - path: "/v1/projects/{projectId}/files", - responses: { - "200": jsonResponse("Project files", schemaRef("ProjectFileList")), - "404": jsonResponse("Not found", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "List persistent uploaded project files", - tags: ["projects"], - }, - { - method: "post", - operationId: "uploadProjectFile", - parameters: [ - { - in: "query", - name: "filename", - required: true, - schema: { maxLength: 255, minLength: 1, type: "string" }, - }, - ], - path: "/v1/projects/{projectId}/files", - requestBody: { - content: { - "application/octet-stream": { - schema: { format: "binary", type: "string" }, - }, - }, - required: true, - }, - responses: { - "201": jsonResponse("Stored project file", schemaRef("ProjectFileUploadResponse")), - "403": jsonResponse("Project is read-only", schemaRef("Error")), - "404": jsonResponse("Not found", schemaRef("Error")), - "413": jsonResponse("File is too large", schemaRef("Error")), - "422": jsonResponse("Unsupported or invalid file", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Upload and version a persistent project file", - tags: ["projects"], - }, - { - method: "get", - operationId: "getProject", - path: "/v1/projects/{projectId}", - responses: { - "200": jsonResponse("Project", schemaRef("Project")), - "404": jsonResponse("Not found", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Get a project", - tags: ["projects"], - }, - { - method: "patch", - operationId: "updateProject", - path: "/v1/projects/{projectId}", - requestBody: jsonBody(schemaRef("UpdateProject")), - responses: { "200": jsonResponse("Updated project", schemaRef("Project")) }, - security: [{ bearerAuth: [] }], - summary: "Update a project", - tags: ["projects"], - }, - { - method: "delete", - operationId: "deleteProject", - path: "/v1/projects/{projectId}", - responses: { "202": emptyResponse("Project deletion accepted") }, - security: [{ bearerAuth: [] }], - summary: "Delete a project", - tags: ["projects"], - }, - { - method: "post", - operationId: "downloadProject", - path: "/v1/projects/{projectId}/download", - responses: { - "200": contentResponse("Project ZIP archive", "application/zip", { - format: "binary", - type: "string", - }), - "404": jsonResponse("Not found", schemaRef("Error")), - "422": jsonResponse("Archive could not be prepared", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Download a project archive", - tags: ["projects"], - }, - { - method: "get", - operationId: "listProjectThreads", - parameters: paginationParameters, - path: "/v1/projects/{projectId}/threads", - responses: { "200": jsonResponse("Threads", paginatedSchema("Thread")) }, - security: [{ bearerAuth: [] }], - summary: "List project threads", - tags: ["threads"], - }, - { - method: "post", - operationId: "createThread", - path: "/v1/threads", - requestBody: jsonBody(schemaRef("CreateThread")), - responses: { "201": jsonResponse("Created thread", schemaRef("Thread")) }, - security: [{ bearerAuth: [] }], - summary: "Create a project-less or project-linked thread", - tags: ["threads"], - }, - { - method: "get", - operationId: "getThread", - path: "/v1/threads/{threadId}", - responses: { "200": jsonResponse("Thread", schemaRef("Thread")) }, - security: [{ bearerAuth: [] }], - summary: "Get a thread", - tags: ["threads"], - }, - { - method: "patch", - operationId: "updateThread", - path: "/v1/threads/{threadId}", - requestBody: jsonBody(schemaRef("UpdateThread")), - responses: { "200": jsonResponse("Updated thread", schemaRef("Thread")) }, - security: [{ bearerAuth: [] }], - summary: "Update a thread", - tags: ["threads"], - }, - { - method: "delete", - operationId: "deleteThread", - path: "/v1/threads/{threadId}", - responses: { "202": emptyResponse("Thread deletion accepted") }, - security: [{ bearerAuth: [] }], - summary: "Delete a thread", - tags: ["threads"], - }, - { - method: "get", - operationId: "listThreadMessages", - parameters: paginationParameters, - path: "/v1/threads/{threadId}/messages", - responses: { "200": jsonResponse("Messages", paginatedSchema("UIMessage")) }, - security: [{ bearerAuth: [] }], - summary: "List thread messages", - tags: ["threads"], - }, -]; diff --git a/apps/gateway-worker/src/openapi-route-parity.ts b/apps/gateway-worker/src/openapi-route-parity.ts deleted file mode 100644 index 06482513..00000000 --- a/apps/gateway-worker/src/openapi-route-parity.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { OPENAPI_ROUTE_IDENTITIES, OPENAPI_ROUTE_KEYS } from "./openapi"; - -interface RuntimeRoute { - method: string; - path: string; -} - -const PUBLIC_METHODS = new Set(["DELETE", "GET", "PATCH", "POST"]); -export const UNMATCHED_GATEWAY_ROUTE = "unmatched"; - -interface GatewayRouteIdentity { - operationId: string; - routeKey: string; -} - -const ROUTE_IDENTITIES = OPENAPI_ROUTE_IDENTITIES.map((route) => ({ - operationId: route.operationId, - pattern: openApiPathPattern(route.path), - routeKey: `${route.method} ${route.path}`, -})); -const ROUTE_IDENTITY_BY_KEY = new Map( - ROUTE_IDENTITIES.map((identity) => [identity.routeKey, identity]), -); - -/** Resolves a code-authored method/template pair to a bounded public operation identity. */ -export function identifyDeclaredGatewayRoute(routeKey: string): GatewayRouteIdentity { - const normalized = normalizeRouteKey(routeKey); - const identity = ROUTE_IDENTITY_BY_KEY.get(normalized); - return identity - ? { operationId: identity.operationId, routeKey: identity.routeKey } - : { operationId: UNMATCHED_GATEWAY_ROUTE, routeKey: UNMATCHED_GATEWAY_ROUTE }; -} - -/** Resolves Hono's matched route template to its stable OpenAPI operation id. */ -export function gatewayOperationIdForRegisteredRoute(method: string, path: string): string { - return identifyDeclaredGatewayRoute(`${method} ${path}`).operationId; -} - -/** Resolves a raw request without ever returning its potentially high-cardinality path. */ -export function gatewayOperationIdForRequest(request: Request): string { - const method = request.method.toUpperCase(); - const pathname = new URL(request.url).pathname; - return ( - ROUTE_IDENTITIES.find( - (identity) => identity.routeKey.startsWith(`${method} `) && identity.pattern.test(pathname), - )?.operationId ?? UNMATCHED_GATEWAY_ROUTE - ); -} - -/** Fails worker startup when the served public routes and published OpenAPI drift apart. */ -export function assertOpenApiRouteParity(runtimeRoutes: readonly RuntimeRoute[]): void { - const runtimeKeys = runtimeRoutes - .filter((route) => PUBLIC_METHODS.has(route.method) && isDocumentedPublicPath(route.path)) - .map((route) => `${route.method} ${toOpenApiPath(route.path)}`); - const duplicateRuntimeKeys = duplicates(runtimeKeys); - const runtimeSet = new Set(runtimeKeys); - const documentedSet = new Set(OPENAPI_ROUTE_KEYS); - const undocumented = [...runtimeSet].filter((key) => !documentedSet.has(key)); - const unserved = [...documentedSet].filter((key) => !runtimeSet.has(key)); - if (duplicateRuntimeKeys.length || undocumented.length || unserved.length) { - throw new Error( - [ - duplicateRuntimeKeys.length - ? `duplicate runtime routes: ${duplicateRuntimeKeys.join(", ")}` - : "", - undocumented.length ? `undocumented routes: ${undocumented.join(", ")}` : "", - unserved.length ? `documented but unserved routes: ${unserved.join(", ")}` : "", - ] - .filter(Boolean) - .join("; "), - ); - } -} - -function isDocumentedPublicPath(path: string): boolean { - return ( - path === "/docs" || path === "/health" || path === "/openapi.json" || path.startsWith("/v1/") - ); -} - -function toOpenApiPath(path: string): string { - return path.replace(/:([A-Za-z][A-Za-z0-9_]*)/g, "{$1}"); -} - -function normalizeRouteKey(routeKey: string): string { - const separator = routeKey.indexOf(" "); - if (separator < 1) { - return UNMATCHED_GATEWAY_ROUTE; - } - const method = routeKey.slice(0, separator).toUpperCase(); - const path = routeKey.slice(separator + 1); - return `${method} ${toOpenApiPath(path)}`; -} - -function openApiPathPattern(path: string): RegExp { - const segments = path.split(/(\{[A-Za-z][A-Za-z0-9_]*\})/g); - const pattern = segments - .map((segment) => (segment.startsWith("{") ? "[^/]+" : escapeRegex(segment))) - .join(""); - return new RegExp(`^${pattern}$`); -} - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function duplicates(values: readonly string[]): string[] { - const seen = new Set(); - const repeated = new Set(); - for (const value of values) { - if (seen.has(value)) { - repeated.add(value); - } - seen.add(value); - } - return [...repeated]; -} diff --git a/apps/gateway-worker/src/openapi-sandbox-routes.ts b/apps/gateway-worker/src/openapi-sandbox-routes.ts deleted file mode 100644 index 6a763466..00000000 --- a/apps/gateway-worker/src/openapi-sandbox-routes.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - BrowserTakeoverResumeResultSchema, - BrowserTakeoverResumeSchema, - BrowserTakeoverSessionSchema, - BrowserTakeoverStatusSchema, - SandboxConsoleSnapshotSchema, - SandboxFileListSchema, - SandboxFilePreviewSchema, - SandboxFileSchema, - SandboxFileWriteSchema, - SandboxIdeSessionSchema, - SandboxPreviewStatusSchema, - SandboxPreviewWakeSchema, - SandboxTerminalCommandSchema, - SandboxTerminalContextSchema, - SandboxTerminalResultSchema, - UpdateSandboxPathFileSchema, -} from "@cheatcode/types"; -import { - type JsonValue, - jsonBody, - jsonResponse, - type OpenApiRoute, - schemaRef, -} from "./openapi-builder"; -import { zodJsonSchema } from "./openapi-zod"; - -export const sandboxSchemas: Record = { - BrowserTakeoverResume: zodJsonSchema(BrowserTakeoverResumeSchema, "input"), - BrowserTakeoverResumeResult: zodJsonSchema(BrowserTakeoverResumeResultSchema), - BrowserTakeoverSession: zodJsonSchema(BrowserTakeoverSessionSchema), - BrowserTakeoverStatus: zodJsonSchema(BrowserTakeoverStatusSchema), - OpenSandboxTerminal: zodJsonSchema(SandboxTerminalCommandSchema, "input"), - SandboxConsoleSnapshot: zodJsonSchema(SandboxConsoleSnapshotSchema), - SandboxFile: zodJsonSchema(SandboxFileSchema), - SandboxFileList: zodJsonSchema(SandboxFileListSchema), - SandboxFilePreview: zodJsonSchema(SandboxFilePreviewSchema), - SandboxFileWrite: zodJsonSchema(SandboxFileWriteSchema), - SandboxIdeSession: zodJsonSchema(SandboxIdeSessionSchema), - SandboxPreviewStatus: zodJsonSchema(SandboxPreviewStatusSchema), - SandboxPreviewWake: zodJsonSchema(SandboxPreviewWakeSchema), - TerminalContext: zodJsonSchema(SandboxTerminalContextSchema), - TerminalPreview: zodJsonSchema(SandboxTerminalResultSchema), - WriteSandboxFile: zodJsonSchema(UpdateSandboxPathFileSchema, "input"), -}; - -const sandboxEncodingParameter: JsonValue = { - in: "query", - name: "encoding", - schema: { enum: ["utf8", "base64"], type: "string" }, -}; - -const sandboxFilePathParameter: JsonValue = { - in: "query", - name: "path", - required: true, - schema: { - maxLength: 1_000, - pattern: "^/workspace(?:/(?!\\.{1,2}(?:/|$))[^/\\u0000]+)*$", - type: "string", - }, -}; - -const sandboxFileListParameters: JsonValue[] = [ - { - in: "query", - name: "path", - schema: { - default: "/workspace", - maxLength: 1_000, - pattern: "^/workspace(?:/(?!\\.{1,2}(?:/|$))[^/\\u0000]+)*$", - type: "string", - }, - }, - { in: "query", name: "recursive", schema: { default: true, type: "boolean" } }, - { in: "query", name: "includeHidden", schema: { default: false, type: "boolean" } }, -]; - -export const sandboxRoutes: OpenApiRoute[] = [ - { - method: "get", - operationId: "getBrowserTakeoverStatus", - path: "/v1/threads/{threadId}/browser-takeover", - responses: { - "200": jsonResponse("Browser takeover status", schemaRef("BrowserTakeoverStatus")), - }, - security: [{ bearerAuth: [] }], - summary: "Read the active browser takeover state", - tags: ["sandbox"], - }, - { - method: "post", - operationId: "startBrowserTakeover", - path: "/v1/threads/{threadId}/browser-takeover/start", - responses: { - "200": jsonResponse("Browser takeover session", schemaRef("BrowserTakeoverSession")), - }, - security: [{ bearerAuth: [] }], - summary: "Pause automation and take over its live browser", - tags: ["sandbox"], - }, - { - method: "post", - operationId: "resumeBrowserAutomation", - path: "/v1/threads/{threadId}/browser-takeover/resume", - requestBody: jsonBody(schemaRef("BrowserTakeoverResume")), - responses: { - "200": jsonResponse("Browser automation resumed", schemaRef("BrowserTakeoverResumeResult")), - }, - security: [{ bearerAuth: [] }], - summary: "Close human control and resume browser automation", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "openComputerIde", - path: "/v1/computer/ide", - responses: { "200": jsonResponse("Computer IDE session", schemaRef("SandboxIdeSession")) }, - security: [{ bearerAuth: [] }], - summary: "Open the user's embedded Computer file viewer", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "readComputerTerminalContext", - path: "/v1/computer/terminal/context", - responses: { "200": jsonResponse("Terminal context", schemaRef("TerminalContext")) }, - security: [{ bearerAuth: [] }], - summary: "Read the user's Computer terminal context", - tags: ["sandbox"], - }, - { - method: "post", - operationId: "openComputerTerminal", - path: "/v1/computer/terminal", - requestBody: jsonBody(schemaRef("OpenSandboxTerminal")), - responses: { "200": jsonResponse("Terminal preview", schemaRef("TerminalPreview")) }, - security: [{ bearerAuth: [] }], - summary: "Run a command in the user's Computer", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "readSandboxConsole", - parameters: [ - { - in: "query", - name: "processId", - schema: { default: "app-preview", maxLength: 200, minLength: 1, type: "string" }, - }, - { in: "query", name: "stdoutCursor", schema: { default: 0, minimum: 0, type: "integer" } }, - { in: "query", name: "stderrCursor", schema: { default: 0, minimum: 0, type: "integer" } }, - { - in: "query", - name: "lastPid", - schema: { maxLength: 100, minLength: 1, type: "string" }, - }, - { - in: "query", - name: "tail", - schema: { default: 200, maximum: 500, minimum: 1, type: "integer" }, - }, - ], - path: "/v1/threads/{threadId}/sandbox/console", - responses: { - "200": jsonResponse("Sandbox console snapshot", schemaRef("SandboxConsoleSnapshot")), - }, - security: [{ bearerAuth: [] }], - summary: "Tail dev-server console output for a thread's sandbox", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "listSandboxFiles", - parameters: sandboxFileListParameters, - path: "/v1/threads/{threadId}/sandbox/files", - responses: { "200": jsonResponse("Sandbox files", schemaRef("SandboxFileList")) }, - security: [{ bearerAuth: [] }], - summary: "List sandbox files for a thread", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "openSandboxIde", - path: "/v1/threads/{threadId}/sandbox/ide", - responses: { "200": jsonResponse("Sandbox IDE session", schemaRef("SandboxIdeSession")) }, - security: [{ bearerAuth: [] }], - summary: "Open the thread's embedded Files viewer", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "getSandboxPreviewStatus", - path: "/v1/threads/{threadId}/sandbox/preview/status", - responses: { - "200": jsonResponse("Sandbox preview state", schemaRef("SandboxPreviewStatus")), - }, - security: [{ bearerAuth: [] }], - summary: "Get the thread's preview sandbox state", - tags: ["sandbox"], - }, - { - method: "post", - operationId: "wakeSandboxPreview", - path: "/v1/threads/{threadId}/sandbox/preview/wake", - responses: { - "200": jsonResponse("Woken sandbox preview", schemaRef("SandboxPreviewWake")), - }, - security: [{ bearerAuth: [] }], - summary: "Wake the thread's preview sandbox and dev server", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "readSandboxFile", - parameters: [sandboxFilePathParameter, sandboxEncodingParameter], - path: "/v1/threads/{threadId}/sandbox/file", - responses: { "200": jsonResponse("Sandbox file", schemaRef("SandboxFile")) }, - security: [{ bearerAuth: [] }], - summary: "Read a sandbox file by path", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "previewSandboxFile", - parameters: [sandboxFilePathParameter], - path: "/v1/threads/{threadId}/sandbox/file-preview", - responses: { "200": jsonResponse("Sandbox file preview", schemaRef("SandboxFilePreview")) }, - security: [{ bearerAuth: [] }], - summary: "Preview a sandbox file by path", - tags: ["sandbox"], - }, - { - method: "patch", - operationId: "writeSandboxFile", - path: "/v1/threads/{threadId}/sandbox/file", - requestBody: jsonBody(schemaRef("WriteSandboxFile")), - responses: { "200": jsonResponse("Sandbox file write", schemaRef("SandboxFileWrite")) }, - security: [{ bearerAuth: [] }], - summary: "Write a sandbox file by path", - tags: ["sandbox"], - }, - { - method: "get", - operationId: "readSandboxTerminalContext", - path: "/v1/threads/{threadId}/sandbox/terminal/context", - responses: { "200": jsonResponse("Terminal context", schemaRef("TerminalContext")) }, - security: [{ bearerAuth: [] }], - summary: "Read sandbox terminal prompt context", - tags: ["sandbox"], - }, - { - method: "post", - operationId: "openSandboxTerminal", - path: "/v1/threads/{threadId}/sandbox/terminal", - requestBody: jsonBody(schemaRef("OpenSandboxTerminal")), - responses: { "200": jsonResponse("Terminal preview", schemaRef("TerminalPreview")) }, - security: [{ bearerAuth: [] }], - summary: "Run a sandbox terminal command", - tags: ["sandbox"], - }, -]; diff --git a/apps/gateway-worker/src/openapi-schema-utils.ts b/apps/gateway-worker/src/openapi-schema-utils.ts deleted file mode 100644 index 331f6b9d..00000000 --- a/apps/gateway-worker/src/openapi-schema-utils.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type JsonValue, schemaRef } from "./openapi-builder"; - -type ComponentSchemas = { [name: string]: JsonValue }; - -export const paginationParameters: JsonValue[] = [ - { - in: "query", - name: "cursor", - schema: { maxLength: 500, minLength: 1, type: "string" }, - }, - { - in: "query", - name: "limit", - schema: { default: 25, maximum: 100, minimum: 1, type: "integer" }, - }, -]; - -export const idempotencyKeyParameter: JsonValue = { - in: "header", - name: "Idempotency-Key", - required: true, - schema: { maxLength: 255, minLength: 1, type: "string" }, -}; - -export function objectSchemaFor(componentSchemas: ComponentSchemas, name: string): JsonValue { - if (!Object.hasOwn(componentSchemas, name)) { - throw new Error(`Missing OpenAPI component schema: ${name}`); - } - return schemaRef(name); -} - -export function arraySchemaFor(componentSchemas: ComponentSchemas, name: string): JsonValue { - return { - items: objectSchemaFor(componentSchemas, name), - type: "array", - }; -} - -export function paginatedSchemaFor(componentSchemas: ComponentSchemas, name: string): JsonValue { - return { - additionalProperties: false, - properties: { - data: arraySchemaFor(componentSchemas, name), - has_more: { type: "boolean" }, - next_cursor: { type: ["string", "null"] }, - }, - required: ["data", "next_cursor", "has_more"], - type: "object", - }; -} diff --git a/apps/gateway-worker/src/openapi-skill-routes.ts b/apps/gateway-worker/src/openapi-skill-routes.ts deleted file mode 100644 index d51c40fe..00000000 --- a/apps/gateway-worker/src/openapi-skill-routes.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { UserSkillSchema, UserSkillsResponseSchema } from "@cheatcode/types"; -import { - emptyResponse, - type JsonValue, - jsonResponse, - type OpenApiRoute, - schemaRef, -} from "./openapi-builder"; -import { zodJsonSchema } from "./openapi-zod"; - -export const skillSchemas: Record = { - UserSkill: zodJsonSchema(UserSkillSchema), - UserSkillsResponse: zodJsonSchema(UserSkillsResponseSchema), -}; - -export const skillRoutes: OpenApiRoute[] = [ - { - method: "get", - operationId: "listUserSkills", - path: "/v1/skills", - responses: { "200": jsonResponse("User skills", schemaRef("UserSkillsResponse")) }, - security: [{ bearerAuth: [] }], - summary: "List the current user's skills", - tags: ["skills"], - }, - { - method: "post", - operationId: "openUserSkill", - path: "/v1/skills/{skillId}/open", - responses: { - "200": jsonResponse("Skill file IDE session", schemaRef("SandboxIdeSession")), - }, - security: [{ bearerAuth: [] }], - summary: "Open a custom skill file in the Computer", - tags: ["skills"], - }, - { - method: "delete", - operationId: "deleteUserSkill", - path: "/v1/skills/{skillId}", - responses: { "204": emptyResponse("User skill deleted") }, - security: [{ bearerAuth: [] }], - summary: "Delete a user skill", - tags: ["skills"], - }, -]; diff --git a/apps/gateway-worker/src/openapi-zod.ts b/apps/gateway-worker/src/openapi-zod.ts deleted file mode 100644 index eeed7525..00000000 --- a/apps/gateway-worker/src/openapi-zod.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type ZodType, z } from "zod"; -import type { JsonValue } from "./openapi-builder"; - -type SchemaIo = "input" | "output"; - -/** Converts the shared runtime contract into the JSON Schema embedded in OpenAPI. */ -export function zodJsonSchema(schema: ZodType, io: SchemaIo = "output"): JsonValue { - const generated: unknown = z.toJSONSchema(schema, { - io, - target: "draft-2020-12", - unrepresentable: "any", - }); - if (!isJsonObject(generated)) { - throw new TypeError("Zod generated a non-object JSON Schema"); - } - return Object.fromEntries(Object.entries(generated).filter(([key]) => key !== "$schema")); -} - -export function withJsonSchemaConstraints( - schema: JsonValue, - constraints: Record, -): JsonValue { - if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { - throw new TypeError("JSON Schema constraints require an object schema"); - } - return { ...schema, ...constraints }; -} - -function isJsonObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).every(isJsonValue) - ); -} - -function isJsonValue(value: unknown): value is JsonValue { - if ( - value === null || - typeof value === "boolean" || - typeof value === "number" || - typeof value === "string" - ) { - return true; - } - if (Array.isArray(value)) { - return value.every(isJsonValue); - } - return isJsonObject(value); -} diff --git a/apps/gateway-worker/src/openapi.ts b/apps/gateway-worker/src/openapi.ts deleted file mode 100644 index 050b36f4..00000000 --- a/apps/gateway-worker/src/openapi.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { - AgentSummarySchema, - ClientErrorBodySchema, - ClientUserEventBodySchema, - CreateRunSchema, - ErrorResponseSchema, - OutputDownloadUrlResponseSchema, - RunStatusSnapshotSchema, - ToolDomainSchema, - ToolSummarySchema, - WebVitalsBodySchema, -} from "@cheatcode/types"; -import { accountRoutes, accountSchemas } from "./openapi-account-routes"; -import { billingRoutes, billingSchemas } from "./openapi-billing-routes"; -import { - buildOpenApiDocument, - contentResponse, - emptyResponse, - type JsonValue, - jsonBody, - jsonResponse, - type OpenApiRoute, - renderOpenApiDocsHtml, - schemaRef, -} from "./openapi-builder"; -import { discoveryRoutes, discoverySchemas } from "./openapi-discovery-routes"; -import { integrationSchemas } from "./openapi-integration-schemas"; -import { projectRoutes, projectSchemas } from "./openapi-project-routes"; -import { sandboxRoutes, sandboxSchemas } from "./openapi-sandbox-routes"; -import { arraySchemaFor, idempotencyKeyParameter, objectSchemaFor } from "./openapi-schema-utils"; -import { skillRoutes, skillSchemas } from "./openapi-skill-routes"; -import { zodJsonSchema } from "./openapi-zod"; - -const COMPONENT_SCHEMAS: Record = { - ...integrationSchemas, - Agent: zodJsonSchema(AgentSummarySchema), - ClientError: zodJsonSchema(ClientErrorBodySchema, "input"), - ClientUserEvent: zodJsonSchema(ClientUserEventBodySchema, "input"), - CreateRun: zodJsonSchema(CreateRunSchema, "input"), - Error: zodJsonSchema(ErrorResponseSchema), - Health: { - additionalProperties: false, - properties: { - agent: { - additionalProperties: false, - properties: { - ok: { const: true, type: "boolean" }, - releaseSha: { type: "string" }, - versionId: { type: ["string", "null"] }, - worker: { const: "agent", type: "string" }, - }, - required: ["ok", "releaseSha", "versionId", "worker"], - type: "object", - }, - ok: { const: true, type: "boolean" }, - releaseSha: { type: "string" }, - versionId: { type: ["string", "null"] }, - }, - required: ["agent", "ok", "releaseSha", "versionId"], - type: "object", - }, - Ok: { - additionalProperties: false, - properties: { ok: { const: true, type: "boolean" } }, - required: ["ok"], - type: "object", - }, - OpenApiDocument: { - additionalProperties: true, - properties: { openapi: { type: "string" } }, - required: ["openapi"], - type: "object", - }, - OutputDownloadUrl: zodJsonSchema(OutputDownloadUrlResponseSchema), - RunStatus: zodJsonSchema(RunStatusSnapshotSchema), - Tool: zodJsonSchema(ToolSummarySchema), - WebVitals: zodJsonSchema(WebVitalsBodySchema, "input"), -}; - -const objectSchema = (name: string): JsonValue => objectSchemaFor(COMPONENT_SCHEMAS, name); -const arraySchema = (name: string): JsonValue => arraySchemaFor(COMPONENT_SCHEMAS, name); - -const runStreamCursorParameter: JsonValue = { - in: "query", - name: "lastSeq", - schema: { default: 0, minimum: 0, type: "integer" }, -}; -const toolDomainParameter: JsonValue = { - in: "query", - name: "domain", - schema: zodJsonSchema(ToolDomainSchema), -}; -const outputDownloadParameters: JsonValue[] = [ - { - in: "query", - name: "expires", - required: true, - schema: { minimum: 1, type: "integer" }, - }, - { - in: "query", - name: "sig", - required: true, - schema: { maxLength: 256, minLength: 32, type: "string" }, - }, - { - in: "query", - name: "userId", - required: true, - schema: { format: "uuid", type: "string" }, - }, -]; - -const routes: OpenApiRoute[] = [ - ...accountRoutes, - ...discoveryRoutes, - ...projectRoutes, - { - method: "post", - operationId: "createThreadRun", - parameters: [idempotencyKeyParameter], - path: "/v1/threads/{threadId}/runs", - requestBody: jsonBody(schemaRef("CreateRun")), - responses: { - "202": { - content: { "text/event-stream": { schema: { type: "string" } } }, - description: "UIMessage SSE stream", - headers: { Location: { schema: { type: "string" } } }, - }, - "400": jsonResponse("Invalid run request", schemaRef("Error")), - "402": jsonResponse("Plan or quota required", schemaRef("Error")), - "403": jsonResponse("Verified email or provider key required", schemaRef("Error")), - "404": jsonResponse("Thread not found", schemaRef("Error")), - "409": jsonResponse("Another run is active", schemaRef("Error")), - "422": jsonResponse("Run could not be prepared", schemaRef("Error")), - "503": jsonResponse("Run service unavailable", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Start an agent run", - tags: ["runs"], - }, - { - method: "get", - operationId: "resumeThreadRunStream", - parameters: [runStreamCursorParameter], - path: "/v1/threads/{threadId}/runs/stream", - responses: { - "200": contentResponse("UIMessage SSE stream", "text/event-stream", { type: "string" }), - "204": emptyResponse("No active stream"), - }, - security: [{ bearerAuth: [] }], - summary: "Resume an agent run stream", - tags: ["runs"], - }, - { - method: "get", - operationId: "getThreadRunStatus", - path: "/v1/threads/{threadId}/runs/status", - responses: { - "200": jsonResponse("Run status", schemaRef("RunStatus")), - "204": emptyResponse("No active run"), - }, - security: [{ bearerAuth: [] }], - summary: "Get active run status for a thread", - tags: ["runs"], - }, - { - method: "post", - operationId: "cancelRun", - path: "/v1/runs/{runId}/cancel", - responses: { "200": jsonResponse("Cancellation accepted", schemaRef("Ok")) }, - security: [{ bearerAuth: [] }], - summary: "Cancel an agent run", - tags: ["runs"], - }, - { - method: "post", - operationId: "createOutputDownloadUrl", - path: "/v1/outputs/{outputId}/download-url", - rateLimited: true, - responses: { - "200": jsonResponse("Short-lived output download URL", schemaRef("OutputDownloadUrl")), - "400": jsonResponse("Invalid output id", schemaRef("Error")), - "404": jsonResponse("Not found", schemaRef("Error")), - "410": jsonResponse("Expired", schemaRef("Error")), - "503": jsonResponse("Signing service unavailable", schemaRef("Error")), - }, - security: [{ bearerAuth: [] }], - summary: "Create a short-lived generated output download URL", - tags: ["outputs"], - }, - { - method: "get", - operationId: "downloadOutput", - parameters: outputDownloadParameters, - path: "/v1/outputs/{outputId}/download", - rateLimited: true, - responses: { - "200": contentResponse("Generated output", "*/*", { - format: "binary", - type: "string", - }), - "400": jsonResponse("Invalid output id or signature query", schemaRef("Error")), - "403": jsonResponse("Invalid signature", schemaRef("Error")), - "404": jsonResponse("Not found", schemaRef("Error")), - "410": jsonResponse("Expired", schemaRef("Error")), - }, - summary: "Download a signed generated output", - tags: ["outputs"], - }, - { - method: "get", - operationId: "listTools", - parameters: [toolDomainParameter], - path: "/v1/tools", - responses: { "200": jsonResponse("Tools", arraySchema("Tool")) }, - security: [{ bearerAuth: [] }], - summary: "List tools", - tags: ["metadata"], - }, - { - method: "get", - operationId: "listAgents", - path: "/v1/agents", - responses: { "200": jsonResponse("Agents", arraySchema("Agent")) }, - security: [{ bearerAuth: [] }], - summary: "List agents", - tags: ["metadata"], - }, - ...sandboxRoutes, - ...skillRoutes, - { - method: "get", - operationId: "listIntegrations", - path: "/v1/integrations", - responses: { "200": jsonResponse("Integrations", arraySchema("Integration")) }, - security: [{ bearerAuth: [] }], - summary: "List Composio integrations", - tags: ["integrations"], - }, - { - method: "get", - operationId: "getIntegrationCatalog", - path: "/v1/integrations/catalog", - responses: { "200": jsonResponse("Catalog", objectSchema("IntegrationCatalog")) }, - security: [{ bearerAuth: [] }], - summary: "Browse the Composio toolkit catalog with per-user connection status", - tags: ["integrations"], - }, - { - method: "get", - operationId: "listToolkitActions", - path: "/v1/integrations/{name}/tools", - responses: { "200": jsonResponse("Toolkit actions", objectSchema("ToolkitActionsResponse")) }, - security: [{ bearerAuth: [] }], - summary: "List a toolkit's available actions", - tags: ["integrations"], - }, - { - method: "post", - operationId: "connectIntegration", - path: "/v1/integrations/{name}/connect", - responses: { "200": jsonResponse("OAuth URL", objectSchema("IntegrationConnect")) }, - security: [{ bearerAuth: [] }], - summary: "Start integration OAuth", - tags: ["integrations"], - }, - { - method: "delete", - operationId: "deleteIntegrationAccount", - path: "/v1/integrations/{name}/accounts/{connectionId}", - responses: { "204": emptyResponse("Connected account deleted") }, - security: [{ bearerAuth: [] }], - summary: "Disconnect one integration account", - tags: ["integrations"], - }, - { - method: "post", - operationId: "makeIntegrationAccountDefault", - path: "/v1/integrations/{name}/accounts/{connectionId}/default", - responses: { "204": emptyResponse("Default connected account updated") }, - security: [{ bearerAuth: [] }], - summary: "Choose the default account for an integration", - tags: ["integrations"], - }, - { - method: "get", - operationId: "listProviderKeys", - path: "/v1/provider-keys", - responses: { "200": jsonResponse("Provider keys", arraySchema("ProviderKeySummary")) }, - security: [{ bearerAuth: [] }], - summary: "List BYOK provider keys", - tags: ["byok"], - }, - { - method: "post", - operationId: "upsertProviderKey", - path: "/v1/provider-keys", - requestBody: jsonBody(objectSchema("UpsertProviderKey")), - responses: { "201": jsonResponse("Provider key summary", objectSchema("ProviderKeySummary")) }, - security: [{ bearerAuth: [] }], - summary: "Store BYOK provider key", - tags: ["byok"], - }, - { - method: "delete", - operationId: "deleteProviderKey", - path: "/v1/provider-keys/{provider}", - responses: { "204": emptyResponse("Provider key deleted") }, - security: [{ bearerAuth: [] }], - summary: "Delete BYOK provider key", - tags: ["byok"], - }, - ...billingRoutes, - { - method: "post", - operationId: "recordClientError", - path: "/v1/client-error", - rateLimited: true, - requestBody: jsonBody(objectSchema("ClientError")), - responses: { "200": jsonResponse("Accepted", objectSchema("Ok")) }, - summary: "Record client error", - tags: ["telemetry"], - }, - { - method: "post", - operationId: "recordVitals", - path: "/v1/vitals", - rateLimited: true, - requestBody: jsonBody(objectSchema("WebVitals")), - responses: { "200": jsonResponse("Accepted", objectSchema("Ok")) }, - summary: "Record web vitals", - tags: ["telemetry"], - }, - { - method: "post", - operationId: "recordClientUserEvent", - path: "/v1/user-events", - requestBody: jsonBody(objectSchema("ClientUserEvent")), - responses: { "200": jsonResponse("Accepted", objectSchema("Ok")) }, - security: [{ bearerAuth: [] }], - summary: "Record authenticated UI activation event", - tags: ["telemetry"], - }, - { - method: "get", - operationId: "getOpenApiDocument", - path: "/openapi.json", - rateLimited: true, - responses: { "200": jsonResponse("OpenAPI document", objectSchema("OpenApiDocument")) }, - summary: "Get OpenAPI document", - tags: ["system"], - }, - { - method: "get", - operationId: "getApiDocs", - path: "/docs", - rateLimited: true, - responses: { - "200": contentResponse("HTML API documentation", "text/html", { type: "string" }), - }, - summary: "Get API docs", - tags: ["system"], - }, - { - method: "get", - operationId: "health", - path: "/health", - rateLimited: true, - responses: { - "200": jsonResponse("Health", objectSchema("Health")), - "503": jsonResponse("Release is converging or a dependency is unhealthy", schemaRef("Error")), - }, - summary: "Health check", - tags: ["system"], - }, -]; - -export const OPENAPI_ROUTE_KEYS = routes.map( - (route) => `${route.method.toUpperCase()} ${route.path}`, -); - -export const OPENAPI_ROUTE_IDENTITIES = routes.map((route) => ({ - method: route.method.toUpperCase(), - operationId: route.operationId, - path: route.path, -})); - -export const OPENAPI_DOCUMENT = buildOpenApiDocument({ - routes, - schemas: { - ...COMPONENT_SCHEMAS, - ...accountSchemas, - ...billingSchemas, - ...discoverySchemas, - ...projectSchemas, - ...sandboxSchemas, - ...skillSchemas, - }, -}); - -export const openApiDocsHtml = (): string => renderOpenApiDocsHtml(routes); diff --git a/apps/gateway-worker/src/provider-http-routes.ts b/apps/gateway-worker/src/provider-http-routes.ts index 3bfdffa0..07b280a3 100644 --- a/apps/gateway-worker/src/provider-http-routes.ts +++ b/apps/gateway-worker/src/provider-http-routes.ts @@ -6,32 +6,14 @@ import { } from "@cheatcode/byok"; import { createDb, lockUserProviderKeyMutations, withUserContext } from "@cheatcode/db"; import { APIError, emitUserEvent, readJsonRequest } from "@cheatcode/observability"; -import { ProviderSchema, ToolDomainSchema, UpsertProviderKeySchema } from "@cheatcode/types"; +import { ProviderSchema, UpsertProviderKeySchema } from "@cheatcode/types"; import { authenticate } from "./authenticate"; import type { GatewayApp, GatewayContext } from "./gateway-env"; -import { listAgentsRoute, listToolsRoute } from "./metadata-routes"; import { rateLimit } from "./rate-limit"; const MAX_PROVIDER_KEY_REQUEST_BYTES = 32 * 1024; export function registerProviderHttpRoutes(app: GatewayApp): void { - app.get("/v1/tools", async (c) => { - const userId = await authenticate(c.req.raw, c.env, c.executionCtx); - await rateLimit(c, userId, "GET /v1/tools"); - const parsedDomain = ToolDomainSchema.optional().safeParse(c.req.query("domain")); - if (!parsedDomain.success) { - throw new APIError(400, "invalid_query_param", "Invalid tool domain", { - details: { issues: parsedDomain.error.issues.map((issue) => issue.message) }, - retriable: false, - }); - } - return listToolsRoute(parsedDomain.data); - }); - app.get("/v1/agents", async (c) => { - const userId = await authenticate(c.req.raw, c.env, c.executionCtx); - await rateLimit(c, userId, "GET /v1/agents"); - return listAgentsRoute(); - }); app.get("/v1/provider-keys", async (c) => { const userId = await authenticate(c.req.raw, c.env, c.executionCtx); await rateLimit(c, userId, "GET /v1/provider-keys"); diff --git a/apps/gateway-worker/src/rate-limit.ts b/apps/gateway-worker/src/rate-limit.ts index 71f75128..a298ea39 100644 --- a/apps/gateway-worker/src/rate-limit.ts +++ b/apps/gateway-worker/src/rate-limit.ts @@ -1,13 +1,7 @@ -import { - APIError, - createLogger, - readBoundedResponseJson, - safeErrorTelemetry, -} from "@cheatcode/observability"; +import { APIError, createLogger, safeErrorTelemetry } from "@cheatcode/observability"; import type { UserId } from "@cheatcode/types"; -import { type RateLimitResult, RateLimitResultSchema } from "./durable-objects/rate-limit-contract"; +import type { RateLimitResult } from "./durable-objects/rate-limit-contract"; import type { RateLimiter } from "./durable-objects/rate-limiter"; -import { identifyDeclaredGatewayRoute } from "./openapi-route-parity"; type RateLimitClass = | "public.read" @@ -51,8 +45,6 @@ interface RateLimitSubject { // A bounded 256-shard pool avoids creating a permanently alarmed Durable Object per client IP. const PUBLIC_RATE_LIMIT_SHARD_PREFIX_LENGTH = 2; -const MAX_RATE_LIMIT_RESPONSE_BYTES = 16 * 1024; - const RATE_LIMIT_POLICIES = { publicRead: { className: "public.read", cost: 1, failClosed: false, limitPerMinute: 300 }, publicWrite: { className: "public.write", cost: 1, failClosed: true, limitPerMinute: 60 }, @@ -67,15 +59,14 @@ export async function rateLimit( userId: UserId, route: string, ): Promise { - const identity = identifyDeclaredGatewayRoute(route); return consumeRateLimit( c, { durableObjectName: `ratelimit:${userId.slice(0, 8)}`, - key: `user:${userId}:${identity.operationId}`, + key: `user:${userId}:${route}`, }, - identity.operationId, - policyForRoute(identity.routeKey), + route, + policyForRoute(route), ); } @@ -84,7 +75,6 @@ export async function rateLimitPublic( route: string, policyName: PublicRateLimitPolicyName, ): Promise { - const identity = identifyDeclaredGatewayRoute(route); const addressHash = await publicClientAddressHash(c.req.raw); return consumeRateLimit( c, @@ -93,9 +83,9 @@ export async function rateLimitPublic( 0, PUBLIC_RATE_LIMIT_SHARD_PREFIX_LENGTH, )}`, - key: `public:${addressHash}:${identity.operationId}`, + key: `public:${addressHash}:${route}`, }, - identity.operationId, + route, RATE_LIMIT_POLICIES[policyName], ); } @@ -103,37 +93,16 @@ export async function rateLimitPublic( async function consumeRateLimit( c: RateLimitContext, subject: RateLimitSubject, - operationId: string, + route: string, policy: RateLimitPolicy, ): Promise { const id = c.env.RATE_LIMITER.idFromName(subject.durableObjectName); const stub = c.env.RATE_LIMITER.get(id); - const body = JSON.stringify({ - key: subject.key, - cost: policy.cost, - config: rateLimitConfig(policy), - }); - let response: Response; - try { - response = await stub.fetch("https://rate-limit.internal/consume", { body, method: "POST" }); - } catch (error) { - return handleRateLimitFailure(operationId, policy, error); - } - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - return handleRateLimitFailure( - operationId, - policy, - new Error(`Rate limiter returned HTTP ${response.status}`), - ); - } let result: RateLimitResult; try { - result = RateLimitResultSchema.parse( - await readBoundedResponseJson(response, MAX_RATE_LIMIT_RESPONSE_BYTES, "Rate limiter"), - ); + result = await stub.consume(subject.key, policy.cost, rateLimitConfig(policy)); } catch (error) { - return handleRateLimitFailure(operationId, policy, error); + return handleRateLimitFailure(route, policy, error); } const headers = rateLimitHeaders(policy, result); if (!result.allowed) { @@ -166,13 +135,13 @@ export function withRateLimitHeaders( } function policyForRoute(route: string): RateLimitPolicy { - if (route === "POST /v1/threads/{threadId}/runs") { + if (route === "POST /v1/threads/:threadId/runs") { return RATE_LIMIT_POLICIES.runsCreate; } - if (route === "POST /v1/projects/{projectId}/download") { + if (route === "POST /v1/projects/:projectId/download") { return RATE_LIMIT_POLICIES.readExpensive; } - if (route === "GET /v1/threads/{threadId}/runs/stream" || isSandboxReadRoute(route)) { + if (route === "GET /v1/threads/:threadId/runs/stream" || isSandboxReadRoute(route)) { return RATE_LIMIT_POLICIES.readExpensive; } if (route.startsWith("GET ")) { @@ -183,7 +152,7 @@ function policyForRoute(route: string): RateLimitPolicy { function isSandboxReadRoute(route: string): boolean { return ( - route.startsWith("GET /v1/threads/{threadId}/sandbox/") || route.startsWith("GET /v1/computer/") + route.startsWith("GET /v1/threads/:threadId/sandbox/") || route.startsWith("GET /v1/computer/") ); } @@ -219,19 +188,15 @@ function rateLimitReset(retryAfterMs: number): string { return String(Math.ceil((Date.now() + resetMs) / 1000)); } -function logRateLimitFailure(operationId: string, error: unknown): void { +function logRateLimitFailure(route: string, error: unknown): void { createLogger().warn("rate_limiter_unavailable", { - route: operationId, + route, ...safeErrorTelemetry(error), }); } -function handleRateLimitFailure( - operationId: string, - policy: RateLimitPolicy, - error: unknown, -): null { - logRateLimitFailure(operationId, error); +function handleRateLimitFailure(route: string, policy: RateLimitPolicy, error: unknown): null { + logRateLimitFailure(route, error); if (policy.failClosed) { throw new APIError(503, "unavailable_maintenance", "Request protection is unavailable", { hint: "Retry shortly. If this persists, check the gateway RateLimiter Durable Object.", diff --git a/apps/gateway-worker/src/usage-summary.ts b/apps/gateway-worker/src/usage-summary.ts index 475b95ab..5caf624d 100644 --- a/apps/gateway-worker/src/usage-summary.ts +++ b/apps/gateway-worker/src/usage-summary.ts @@ -1,17 +1,12 @@ import { quotaPeriodEndFor, sandboxHoursWarnLevel } from "@cheatcode/billing"; import type { Database } from "@cheatcode/db"; -import { APIError, readBoundedResponseJson } from "@cheatcode/observability"; +import { APIError } from "@cheatcode/observability"; import { type SandboxUsageSummaryResponse, SandboxUsageSummaryResponseSchema, type UserId, } from "@cheatcode/types"; -import { - QUOTA_FEATURES, - QUOTA_TRACKER_MAX_RESPONSE_BYTES, - QuotaPeekRequestSchema, - QuotaUsageResponseSchema, -} from "@cheatcode/types/quota"; +import { QUOTA_FEATURES } from "@cheatcode/types/quota"; import { type LimitBindings, resolveEntitlement, syncQuotaLimits } from "./limits"; /** @@ -44,24 +39,15 @@ async function peekSandboxHoursUsed( periodEnd: Date, ): Promise { const stub = env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`)); - const body = QuotaPeekRequestSchema.parse({ - feature: QUOTA_FEATURES.sandboxHours, - periodEnd: periodEnd.toISOString(), - }); - const response = await stub.fetch("https://quota.internal/peek", { - body: JSON.stringify(body), - method: "POST", - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); + try { + return (await stub.peek(QUOTA_FEATURES.sandboxHours, periodEnd)).used; + } catch (error) { throw new APIError(503, "unavailable_maintenance", "Quota tracker is unavailable", { + cause: error, hint: "Retry the request. If it persists, check the QuotaTracker Durable Object logs.", retriable: true, }); } - return QuotaUsageResponseSchema.parse( - await readBoundedResponseJson(response, QUOTA_TRACKER_MAX_RESPONSE_BYTES, "Quota tracker"), - ).used; } function round1(value: number): number { diff --git a/apps/preview-proxy/README.md b/apps/preview-proxy/README.md index a7c11bb9..651065a9 100644 --- a/apps/preview-proxy/README.md +++ b/apps/preview-proxy/README.md @@ -212,12 +212,9 @@ the shared `@cheatcode/observability` emitters. for Clerk, documentation, and the Vercel frontend hostname. `preview.trycheatcode.com` deliberately has no exact route and inherits the preview wildcard for release health checks. -- [`infra/cloudflare/production-route-contract.json`](../../infra/cloudflare/production-route-contract.json) - is the production contract. `stage-closed` creates only a missing exact - no-script route through the Workers Routes API before deploying the wildcard; - it never updates or deletes an existing route. Conflicts, semantic duplicates, - and overlapping wildcard routes stop the release. The contract is checked - again after preview deployment and before the gateway can reopen. +- The wildcard route is declared in + [`apps/preview-proxy/wrangler.jsonc`](./wrangler.jsonc) and deployed directly + by [`.github/workflows/deploy-cloudflare.yml`](../../.github/workflows/deploy-cloudflare.yml). - DNS: a **proxied** wildcard record `*` -> the zone (orange-cloud) so Cloudflare terminates TLS and runs this Worker for every sub-subdomain. - TLS: the wildcard is one label deep beyond the configured apex; Cloudflare diff --git a/apps/preview-proxy/package.json b/apps/preview-proxy/package.json index 11ad0304..6796a775 100644 --- a/apps/preview-proxy/package.json +++ b/apps/preview-proxy/package.json @@ -7,7 +7,6 @@ "types": "./src/index.ts", "scripts": { "build": "wrangler deploy --dry-run", - "deploy": "wrangler deploy", "lint": "biome check .", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/preview-proxy/src/index.ts b/apps/preview-proxy/src/index.ts index b15284f6..9393df2b 100644 --- a/apps/preview-proxy/src/index.ts +++ b/apps/preview-proxy/src/index.ts @@ -1,13 +1,5 @@ import { resolveWorkerSecret } from "@cheatcode/env"; -import { - APIError, - createLogger, - emitErrorEvent, - emitPerformanceMetric, - safeErrorTelemetry, - toAPIError, - withErrorHandler, -} from "@cheatcode/observability"; +import { APIError, createWorkerRuntime, routeName } from "@cheatcode/observability"; import { type PreviewProxyEnv, PreviewProxyEnvSchema } from "./env"; import { type PreviewTarget, parsePreviewHost } from "./host"; import { @@ -173,20 +165,6 @@ function buildSessionCookie( return `${name}=${token}; HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=${maxAgeSeconds}`; } -function requestId(): string { - return `req_${crypto.randomUUID().replaceAll("-", "")}`; -} - -function withRequestId(response: Response, id: string): Response { - // A 101 WebSocket upgrade response cannot be cloned/re-headered, so pass it through. - if (response.status === 101 || response.webSocket) { - return response; - } - const wrapped = new Response(response.body, response); - wrapped.headers.set("X-Request-Id", id); - return wrapped; -} - function withPreviewSecurityHeaders(response: Response, appOrigin?: string): Response { if (response.status === 101 || response.webSocket) { return response; @@ -221,10 +199,6 @@ function appendSecurityVary(headers: Headers): void { headers.set("Vary", vary.join(", ")); } -function routeName(request: Request): string { - return `${request.method} ${new URL(request.url).pathname}`; -} - function requestContextTelemetry(request: Request) { return { fetchDestination: request.headers.get("Sec-Fetch-Dest") ?? "missing", @@ -247,66 +221,30 @@ function safeHeaderOrigin(value: string | null): string { } } -function statusClass(status: number): string { - if (status >= 500) { - return "5xx"; - } - if (status >= 400) { - return "4xx"; - } - if (status >= 300) { - return "3xx"; - } - return "2xx"; -} - -const previewProxyHandler = { - async fetch(request: Request, env: PreviewProxyEnv): Promise { - const id = requestId(); - const startedAt = performance.now(); - let status = 500; - let appOrigin: string | undefined; - try { - PreviewProxyEnvSchema.parse(env); - appOrigin = env.CHEATCODE_APP_ORIGIN; - const response = withPreviewSecurityHeaders( - withRequestId(await handlePreviewRequest(request, env), id), - appOrigin, - ); - status = response.status; - return response; - } catch (error) { - const apiError = toAPIError(error); - status = apiError.status; - emitErrorEvent(env, { - errorCategory: WORKER_NAME, - errorCode: apiError.code, - httpStatus: apiError.status, - route: routeName(request), - workerName: WORKER_NAME, - ...safeErrorTelemetry(error), - }); - createLogger({ requestId: id }).error("preview_proxy_request_failed", { - apiCode: apiError.code, - httpStatus: apiError.status, - ...(apiError.code === "permission_denied" ? requestContextTelemetry(request) : {}), - ...safeErrorTelemetry(error), - }); - return withPreviewSecurityHeaders(apiError.toResponse(id), appOrigin); - } finally { - emitPerformanceMetric(env, { - route: WORKER_NAME, - statusClass: statusClass(status), - totalMs: performance.now() - startedAt, - workerName: WORKER_NAME, - }); - } - }, -}; - -export default withErrorHandler(previewProxyHandler, { +const previewProxyHandler = createWorkerRuntime({ errorCategory: WORKER_NAME, - requestId: (request) => request.headers.get("X-Request-Id"), + errorLogFields: ({ apiError, request }) => ({ + httpStatus: apiError.status, + ...(apiError.code === "permission_denied" ? requestContextTelemetry(request) : {}), + }), + errorLogName: "preview_proxy_request_failed", + fetch: async (request, env) => { + PreviewProxyEnvSchema.parse(env); + return withPreviewSecurityHeaders( + await handlePreviewRequest(request, env), + env.CHEATCODE_APP_ORIGIN, + ); + }, + formatError: ({ apiError, env, requestId: id }) => + withPreviewSecurityHeaders(apiError.toResponse(id), validatedAppOrigin(env)), + performanceRouteName: () => WORKER_NAME, routeName, workerName: WORKER_NAME, }); + +function validatedAppOrigin(env: PreviewProxyEnv): string | undefined { + const parsed = PreviewProxyEnvSchema.safeParse(env); + return parsed.success ? parsed.data.CHEATCODE_APP_ORIGIN : undefined; +} + +export default previewProxyHandler; diff --git a/apps/preview-proxy/src/proxy.ts b/apps/preview-proxy/src/proxy.ts index 65bf4e59..cbc209ae 100644 --- a/apps/preview-proxy/src/proxy.ts +++ b/apps/preview-proxy/src/proxy.ts @@ -228,6 +228,8 @@ function buildForwardHeaders( headers.append(key, value); } }); + // Edge-internal telemetry id; user-app dev servers must see the request as sent. + headers.delete("X-Request-Id"); stripProxyCredentials(headers); headers.set(DAYTONA_TOKEN_HEADER, origin.token); headers.set(DAYTONA_SKIP_WARNING_HEADER, "true"); diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 8ba9a7a8..b8173090 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,6 +1,12 @@ import type { ReactNode } from "react"; -import { ProtectedAppShell } from "@/components/auth/protected-app-shell"; +import { AuthRequiredGate } from "@/components/auth/auth-required-gate"; +import { AppChrome } from "@/components/shell/app-chrome"; +import { WorkspaceLoadingState } from "@/components/workspace/workspace-route-state"; export default function AppLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + }>{children} + + ); } diff --git a/apps/web/src/app/chat-markdown.css b/apps/web/src/app/chat-markdown.css deleted file mode 100644 index 693ca4cf..00000000 --- a/apps/web/src/app/chat-markdown.css +++ /dev/null @@ -1,185 +0,0 @@ -.prose.chat-markdown { - & > * + * { - margin-top: 0.75em; - } - - & h1 { - margin-top: 1em; - margin-bottom: 0.5em; - font-weight: 700; - font-size: 1.5rem; - line-height: 1.2; - } - - & h2 { - margin-top: 0.8em; - margin-bottom: 0.4em; - font-weight: 600; - font-size: 1.25rem; - line-height: 1.3; - } - - & h3 { - margin-top: 0.6em; - margin-bottom: 0.3em; - font-weight: 600; - font-size: 1.125rem; - } - - & h4, - & h5, - & h6 { - margin-top: 0.5em; - margin-bottom: 0.25em; - font-weight: 600; - font-size: 1rem; - } - - & li > ul, - & li > ol { - margin-top: 0.25em; - margin-bottom: 0.25em; - } - - & ul, - & ol { - margin-top: 0.5em; - margin-bottom: 0.5em; - padding-left: 1.5em; - } - - & ul { - list-style-type: disc; - } - - & ol { - list-style-type: decimal; - } - - & li { - margin-top: 0.25em; - margin-bottom: 0.25em; - } - - & pre { - margin-top: 0.5em; - margin-bottom: 0.5em; - overflow-x: auto; - border-radius: 0.375rem; - background-color: rgba(9, 9, 11, 0.95); - padding: 0.75em 1em; - font-family: var(--font-mono); - } - - & pre code { - overflow-wrap: normal; - background-color: transparent; - padding: 0; - color: inherit; - font-size: 0.9em; - font-family: var(--font-mono); - white-space: pre; - word-break: normal; - } - - & code:not([class*="language-"]) { - border-radius: 3px; - background-color: var(--bg-secondary); - padding: 0.2em 0.4em; - font-size: 0.85em; - font-family: var(--font-mono); - word-break: break-word; - } - - & table { - width: 100%; - margin-top: 0.75em; - margin-bottom: 0.75em; - border-collapse: collapse; - font-size: 0.9em; - } - - & th { - background-color: var(--bg-secondary); - padding: 0.5em 0.75em; - font-weight: 600; - text-align: left; - } - - & td { - border: 1px solid #e2e8f0; - padding: 0.5em 0.75em; - } - - & blockquote { - margin-left: 0; - border-left: 3px solid #cbd5e1; - padding-left: 1em; - color: #475569; - font-style: italic; - } - - .dark & { - & pre { - background-color: rgba(9, 9, 11, 0.95); - } - - & code:not([class*="language-"]) { - background-color: rgba(9, 9, 11, 0.95); - color: #e4e4e7; - } - - & th { - border-color: #3f3f46; - background-color: #27272a; - } - - & td { - border-color: #3f3f46; - } - - & blockquote { - border-left-color: #52525b; - color: #a1a1aa; - } - - & .hljs-keyword, - & .hljs-selector-tag, - & .hljs-built_in, - & .hljs-name, - & .hljs-tag { - color: #60a5fa; - } - - & .hljs-string, - & .hljs-title, - & .hljs-section, - & .hljs-attribute, - & .hljs-literal, - & .hljs-template-tag, - & .hljs-template-variable, - & .hljs-type, - & .hljs-addition { - color: #4ade80; - } - - & .hljs-comment, - & .hljs-quote, - & .hljs-deletion, - & .hljs-meta { - color: #71717a; - } - - & .hljs-keyword, - & .hljs-selector-tag, - & .hljs-literal, - & .hljs-title, - & .hljs-section, - & .hljs-doctag, - & .hljs-type, - & .hljs-name, - & .hljs-strong { - font-weight: bold; - } - } -} diff --git a/apps/web/src/app/effects.css b/apps/web/src/app/effects.css index 8835bbd7..9dc875e9 100644 --- a/apps/web/src/app/effects.css +++ b/apps/web/src/app/effects.css @@ -1,53 +1,5 @@ @reference "./globals.css"; -.gradient-home-bg { - background-image: linear-gradient( - 156.2deg, - rgba(0, 0, 0, 1) 14.8%, - rgba(32, 104, 177, 1) 68.1%, - rgba(222, 229, 237, 1) 129% - ); -} - -.bg-checker-pattern { - background-position: - 0 0, - 0 10px, - 10px -10px, - -10px 0; - background-size: 20px 20px; - background-color: #fff; - background-image: - linear-gradient(45deg, #f3f4f6 25%, transparent 25%), - linear-gradient(-45deg, #f3f4f6 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, #f3f4f6 75%), - linear-gradient(-45deg, transparent 75%, #f3f4f6 75%); -} - -.dark .bg-checker-pattern-dark { - background-position: - 0 0, - 0 10px, - 10px -10px, - -10px 0; - background-size: 20px 20px; - background-color: rgba(24, 24, 27, 0.7); - background-image: - linear-gradient(45deg, rgba(39, 39, 42, 0.7) 25%, transparent 25%), - linear-gradient(-45deg, rgba(39, 39, 42, 0.7) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, rgba(39, 39, 42, 0.7) 75%), - linear-gradient(-45deg, transparent 75%, rgba(39, 39, 42, 0.7) 75%); -} - -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(200%); - } -} - @keyframes cc-heartbeat { 0%, 100% { @@ -67,96 +19,10 @@ } } -@keyframes gradient-shift { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -@theme { - --animate-shimmer: shimmer 1s infinite; - --animate-gradient-shift: gradient-shift 2s ease infinite; -} - -.animate-shimmer { - @apply relative overflow-hidden rounded-md; - @apply bg-gradient-to-r from-primary/10 via-primary/5 to-primary/10; - background-size: 200% 200%; - animation: var(--animate-gradient-shift); -} - -.animate-shimmer::before { - @apply absolute top-0 left-0 w-full h-full; - width: 100%; - transform: translateX(-100%); - animation: var(--animate-shimmer); - background: linear-gradient( - 140deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0.3) 50%, - rgba(255, 255, 255, 0) 100% - ); - content: ""; -} - -.animate-shimmer-gray { - @apply relative overflow-hidden rounded-md; - @apply bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200; - background-size: 200% 200%; - animation: var(--animate-gradient-shift); -} - -.animate-shimmer-gray::before { - @apply absolute top-0 left-0 w-full h-full; - width: 100%; - transform: translateX(-100%); - animation: var(--animate-shimmer); - background: linear-gradient( - 140deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0.8) 50%, - rgba(255, 255, 255, 0) 100% - ); - content: ""; -} - -@theme inline { - --animate-shiny-text: shiny-text 5s infinite; - --animate-liquid-shimmer: liquid-shimmer 2s ease-in-out infinite; - - @keyframes shiny-text { - 0%, - 90%, - 100% { - background-position: calc(-100% - var(--shiny-width)) 0; - } - 30%, - 60% { - background-position: calc(100% + var(--shiny-width)) 0; - } - } - - @keyframes liquid-shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } - } -} - @media (prefers-reduced-motion: reduce) { .cheatcode-composer-working, .cc-fade-in, - .animate-spin, - .animate-pulse { + .animate-spin { animation: none; } } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a9c634b4..0cd9a415 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -150,19 +150,6 @@ --color-foreground: var(--foreground); --font-sans: "circular", var(--font-geist-sans); --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); @@ -178,25 +165,19 @@ --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-bg-primary: var(--bg-primary); --color-bg-elevated: var(--bg-elevated); --color-bg-secondary: var(--bg-secondary); --color-bg-lifted: var(--bg-lifted); --color-bg-inverse: var(--bg-inverse); - --color-fg-brand: var(--fg-brand); --color-fg-primary: var(--fg-primary); --color-fg-secondary: var(--fg-secondary); --color-fg-tertiary: var(--fg-tertiary); --color-fg-inverse: var(--fg-inverse); --color-brand-accent-fg: var(--brand-accent-fg); - --color-border-default: var(--border-default); --color-border-subtle: var(--border-subtle); --color-border-tree: var(--border-tree); --color-placeholder: var(--placeholder); --color-success-fg: var(--success-fg); - --color-success-bg: var(--success-bg); --color-danger-fg: var(--danger-fg); --color-danger-bg: var(--danger-bg); --radius-sm: calc(var(--radius) - 4px); @@ -204,216 +185,21 @@ --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - /* Thread Screen Color System - Tailwind integration */ - --color-thread-panel: var(--thread-panel-bg); - --color-thread-panel-translucent: var(--thread-panel-bg-translucent); - --color-thread-surface: var(--thread-surface); --color-thread-surface-hover: var(--thread-surface-hover); - --color-thread-surface-subtle: var(--thread-surface-subtle); --color-thread-border: var(--thread-border); --color-thread-border-subtle: var(--thread-border-subtle); - --color-thread-border-hover: var(--thread-border-hover); - --color-thread-divider: var(--thread-divider); --color-thread-text-primary: var(--thread-text-primary); --color-thread-text-secondary: var(--thread-text-secondary); --color-thread-text-tertiary: var(--thread-text-tertiary); --color-thread-text-muted: var(--thread-text-muted); - --color-thread-text-placeholder: var(--thread-text-placeholder); - --color-thread-hover: var(--thread-hover-bg); - --color-thread-active: var(--thread-active-bg); - --color-thread-status-success: var(--thread-status-success); - --color-thread-status-success-bg: var(--thread-status-success-bg); - --color-thread-status-warning: var(--thread-status-warning); - --color-thread-status-warning-bg: var(--thread-status-warning-bg); - --color-thread-status-error: var(--thread-status-error); - --color-thread-status-error-bg: var(--thread-status-error-bg); - --color-thread-status-info: var(--thread-status-info); --color-thread-accent: var(--thread-accent-purple); --color-thread-accent-purple: var(--thread-accent-purple); - --color-thread-skeleton: var(--thread-skeleton-bg); - --color-thread-loading: var(--thread-loading-dot); - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; - --animate-marquee: marquee var(--duration) infinite linear; - --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; - --animate-orbit: orbit calc(var(--duration) * 1s) linear infinite; - --animate-slide-down: slide-down 300ms cubic-bezier(0.87, 0, 0.13, 1); - --animate-slide-up: slide-up 300ms cubic-bezier(0.87, 0, 0.13, 1); - --scaleIn: scaleIn 200ms ease; - --scaleOut: scaleOut 200ms ease; - --fadeIn: fadeIn 200ms ease; - --fadeOut: fadeOut 200ms ease; - --enterFromLeft: enterFromLeft 250ms ease; - --enterFromRight: enterFromRight 250ms ease; - --exitToLeft: exitToLeft 250ms ease; - --exitToRight: exitToRight 250ms ease; - --animate-elliptical-orbit: elliptical-orbit 20s linear infinite; - @keyframes orbit { - 0% { - transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) - rotate(calc(var(--angle) * -1deg)); - } - 100% { - transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) - rotate(calc((var(--angle) * -1deg) - 360deg)); - } - } - - @keyframes accordion-down { - from { - height: 0; - } - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } - - @keyframes marquee { - from { - transform: translateX(0); - } - to { - transform: translateX(calc(-100% - var(--gap))); - } - } - - @keyframes marquee-vertical { - from { - transform: translateY(0); - } - to { - transform: translateY(calc(-100% - var(--gap))); - } - } - @keyframes slide-down { - from { - height: 0px; - } - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes slide-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0px; - } - } - - /* Add this to your globals.css */ - @keyframes enterFromRight { - from { - opacity: 0; - transform: translateX(200px); - } - to { - opacity: 1; - transform: translateX(0); - } - } - - @keyframes enterFromLeft { - from { - opacity: 0; - transform: translateX(-200px); - } - to { - opacity: 1; - transform: translateX(0); - } - } - - @keyframes exitToRight { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(200px); - } - } - - @keyframes exitToLeft { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(-200px); - } - } - - @keyframes scaleIn { - from { - opacity: 0; - transform: rotateX(-30deg) scale(0.9); - } - to { - opacity: 1; - transform: rotateX(0deg) scale(1); - } - } - - @keyframes scaleOut { - from { - opacity: 1; - transform: rotateX(0deg) scale(1); - } - to { - opacity: 0; - transform: rotateX(-10deg) scale(0.95); - } - } - - @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - @keyframes fadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } - } - - @keyframes elliptical-orbit { - from { - transform: rotate(calc(var(--angle, 0) * 1deg)) translate(var(--h-radius, 160px), 0) - rotate(calc(var(--angle, 0) * -1deg)); - } - to { - transform: rotate(calc((var(--angle, 0) * 1deg) + 360deg)) - translate(var(--h-radius, 160px), 0) rotate(calc((var(--angle, 0) * -1deg) - 360deg)); - } - } } :root { --neutral-0: #fff; --neutral-50: #f7f7f7; --neutral-100: #f8f8f8; - --neutral-200: #eee; --neutral-250: #e4e4e4; --neutral-300: #bebebe; --neutral-400: #9b9b9b; @@ -428,7 +214,6 @@ --neutral-1000: #121212; --accent-p-300: #f9b96c; --accent-p-500: #fba62a; - --accent-p-700: #bb7400; --destructive-500: #cc272e; --destructive-400: #e85854; --bg-primary: var(--neutral-0); @@ -436,7 +221,6 @@ --bg-secondary: var(--neutral-100); --bg-lifted: var(--neutral-50); --bg-inverse: var(--neutral-950); - --fg-brand: var(--neutral-800); --fg-primary: var(--neutral-950); --fg-secondary: var(--neutral-500); --fg-tertiary: var(--neutral-700); @@ -446,11 +230,8 @@ --border-subtle: var(--neutral-100); --border-tree: var(--neutral-250); --accent-default: var(--accent-p-500); - --accent-muted: var(--accent-p-300); --background: var(--bg-primary); --foreground: var(--fg-primary); - --card: var(--bg-primary); - --card-foreground: var(--fg-primary); --popover: var(--bg-primary); --popover-foreground: var(--fg-primary); --primary: var(--accent-default); @@ -468,90 +249,24 @@ --placeholder: var(--neutral-500); --focus-ring: var(--neutral-950); --ring: var(--focus-ring); - --chart-1: #b84b00; - --chart-2: #008479; - --chart-3: #144e66; - --chart-4: #d6a62e; - --chart-5: #d58d25; --radius: 0.625rem; - --sidebar: var(--bg-elevated); - --sidebar-foreground: var(--fg-primary); - --sidebar-primary: var(--fg-primary); - --sidebar-primary-foreground: var(--fg-inverse); - --sidebar-accent: var(--bg-elevated); - --sidebar-accent-foreground: var(--fg-primary); - --sidebar-border: var(--border-default); - --sidebar-ring: var(--neutral-400); --success-fg: #007a77; - --success-bg: #e7f1f0; --danger-fg: #9a504b; --danger-bg: #f5eceb; --sidebar-lifted-shadow: 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent), inset 0 0 2px color-mix(in oklch, var(--foreground) 2%, transparent); - /* ============================================ - * THREAD SCREEN COLOR SYSTEM - * All thread-related colors centralized here - * ============================================ */ - - /* Thread Panel Backgrounds */ - --thread-panel-bg: var(--bg-primary); - --thread-panel-bg-translucent: color-mix(in srgb, var(--bg-primary) 88%, transparent); - --thread-surface: var(--bg-secondary); --thread-surface-hover: var(--bg-elevated); - --thread-surface-subtle: color-mix(in srgb, var(--bg-secondary) 68%, transparent); - - /* Thread Borders */ --thread-border: var(--border-default); --thread-border-subtle: var(--border-subtle); - --thread-border-hover: var(--border-tree); - --thread-divider: var(--border-tree); - - /* Thread Text Colors */ --thread-text-primary: var(--fg-primary); --thread-text-secondary: var(--fg-secondary); --thread-text-tertiary: var(--fg-tertiary); --thread-text-muted: var(--placeholder); - --thread-text-placeholder: var(--placeholder); - - /* Thread Interactive States */ - --thread-hover-bg: var(--bg-elevated); - --thread-active-bg: var(--bg-secondary); - - /* Thread Status Colors */ - --thread-status-success: var(--success-fg); - --thread-status-success-bg: var(--success-bg); - --thread-status-success-border: color-mix(in srgb, var(--success-fg) 20%, transparent); - --thread-status-success-glow: color-mix(in srgb, var(--success-fg) 22%, transparent); - - --thread-status-warning: var(--accent-default); - --thread-status-warning-bg: color-mix(in srgb, var(--accent-default) 14%, transparent); - --thread-status-warning-border: color-mix(in srgb, var(--accent-default) 30%, transparent); - - --thread-status-error: var(--danger-fg); - --thread-status-error-bg: var(--danger-bg); - --thread-status-error-border: color-mix(in srgb, var(--danger-fg) 22%, transparent); - --thread-status-error-text: var(--danger-fg); - - --thread-status-info: var(--fg-secondary); - - /* Thread Accent Colors */ --thread-accent-purple: var(--accent-default); - --thread-accent-purple-glow: color-mix(in srgb, var(--accent-default) 18%, transparent); - - /* Thread Message Bubbles */ --thread-user-message-bg: #f5f5f5; - --thread-assistant-message-bg: var(--bg-primary); - --thread-assistant-message-border: var(--border-default); - - /* Thread Code/Tool Colors */ --thread-code-bg: var(--bg-secondary); - --thread-tool-border: var(--border-default); - - /* Thread Skeleton/Loading */ - --thread-skeleton-bg: var(--bg-elevated); - --thread-loading-dot: var(--accent-default); } .dark { @@ -560,7 +275,6 @@ --bg-secondary: var(--neutral-950); --bg-lifted: var(--neutral-975); --bg-inverse: var(--neutral-50); - --fg-brand: var(--neutral-500); --fg-primary: var(--neutral-50); --fg-secondary: var(--neutral-400); --fg-tertiary: var(--neutral-300); @@ -571,8 +285,6 @@ --border-tree: var(--neutral-850); --background: var(--bg-primary); --foreground: var(--fg-primary); - --card: var(--bg-elevated); - --card-foreground: var(--fg-primary); --popover: var(--bg-elevated); --popover-foreground: var(--fg-primary); --primary: var(--accent-default); @@ -590,21 +302,7 @@ --placeholder: var(--neutral-400); --focus-ring: var(--neutral-50); --ring: var(--focus-ring); - --chart-1: #3c68d9; - --chart-2: #1ea87b; - --chart-3: #d58d25; - --chart-4: #9b5ad9; - --chart-5: #d64651; - --sidebar: var(--bg-elevated); - --sidebar-foreground: var(--fg-primary); - --sidebar-primary: var(--accent-default); - --sidebar-primary-foreground: var(--fg-inverse); - --sidebar-accent: var(--bg-secondary); - --sidebar-accent-foreground: var(--fg-primary); - --sidebar-border: var(--border-default); - --sidebar-ring: var(--neutral-600); --success-fg: #a9d9d6; - --success-bg: #007a772e; --danger-fg: #edc2bd; --danger-bg: #9a504b2e; --sidebar-lifted-shadow: @@ -638,19 +336,6 @@ padding: 0; } - /* Add font fallbacks for CJK characters */ - .cjk-text, - .prose p, - .prose li, - .prose table td, - .prose table th, - .markdown-content { - font-family: - var(--font-sans), ui-sans-serif, -apple-system, "Segoe UI", "Helvetica Neue", "Noto Sans", - "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", sans-serif; - line-height: 1.7; - } - /* Specific handling for monospace/code with CJK character support */ code, pre, @@ -669,12 +354,6 @@ background: color-mix(in srgb, var(--bg-secondary) 60%, transparent); } - .paper-card { - border: 1px solid var(--border-default); - background: var(--bg-primary); - box-shadow: 0 0 1px rgba(0, 0, 0, 0.08); - } - .cheatcode-composer-shell { border: 2px solid var(--border-default); background: var(--bg-primary); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c65c556d..7cc7205b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,7 +6,6 @@ import { GeistSans } from "geist/font/sans"; import type { Metadata } from "next"; import type { ReactNode } from "react"; import "./globals.css"; -import "./chat-markdown.css"; import "./effects.css"; import { ClientObservability } from "@/components/observability/client-observability"; import { Providers } from "./providers"; diff --git a/apps/web/src/components/auth/protected-app-shell.tsx b/apps/web/src/components/auth/protected-app-shell.tsx deleted file mode 100644 index 1a7f35ee..00000000 --- a/apps/web/src/components/auth/protected-app-shell.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { AuthRequiredGate } from "@/components/auth/auth-required-gate"; -import { AppChrome } from "@/components/shell/app-chrome"; -import { WorkspaceLoadingState } from "@/components/workspace/workspace-route-state"; - -export function ProtectedAppShell({ children }: { children: ReactNode }) { - return ( - - }>{children} - - ); -} diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx index 1b0b6ce2..381501fd 100644 --- a/apps/web/src/components/chat/chat-panel.tsx +++ b/apps/web/src/components/chat/chat-panel.tsx @@ -2,7 +2,8 @@ import { ChatContextRow } from "@/components/chat/chat-context-row"; import { MessageList } from "@/components/chat/message-list"; -import { PromptComposer } from "@/components/chat/prompt-composer"; +import { usePromptComposerController } from "@/components/chat/prompt-composer-controller"; +import { PromptComposerView } from "@/components/chat/prompt-composer-view"; import { StreamReconnectBanner } from "@/components/chat/stream-reconnect-banner"; import { type ChatPanelProps, @@ -11,6 +12,16 @@ import { export function ChatPanel(props: ChatPanelProps) { const controller = useChatPanelController(props); + const promptComposerController = usePromptComposerController({ + onChange: controller.actions.setDraft, + onStop: controller.actions.stopRun, + onSubmit: controller.actions.submitText, + project: props.project, + resolvedModelId: props.latestModelId, + status: controller.state.composerStatus, + threadId: props.threadId, + value: controller.state.draft, + }); return (
- +
); } diff --git a/apps/web/src/components/chat/message-activity.tsx b/apps/web/src/components/chat/message-activity.tsx index 9121be53..ab5bef84 100644 --- a/apps/web/src/components/chat/message-activity.tsx +++ b/apps/web/src/components/chat/message-activity.tsx @@ -72,7 +72,6 @@ const TOOL_VERBS: Record = { search_web: { verb: "Searched the web", argKeys: ["query", "q"] }, search_web_advanced: { verb: "Searched the web", argKeys: ["query", "q"] }, search_company: { verb: "Researched a company", argKeys: ["company", "name", "query"] }, - research_competitor: { verb: "Researched competitors", argKeys: ["query", "company"] }, research_deep: { verb: "Researched", argKeys: ["query", "topic"] }, research_fanout: { verb: "Researched", argKeys: ["query", "topic"] }, composio_execute: { verb: "Ran an app action", argKeys: ["tool", "action", "slug"] }, @@ -135,7 +134,7 @@ function ActivityNarration({ text }: { text: string }) { return null; } return ( -
+
{text}
); diff --git a/apps/web/src/components/chat/message-parts.tsx b/apps/web/src/components/chat/message-parts.tsx index e1f8204f..5513545f 100644 --- a/apps/web/src/components/chat/message-parts.tsx +++ b/apps/web/src/components/chat/message-parts.tsx @@ -83,7 +83,7 @@ function UserMessageParts({ message }: { message: CheatcodeUIMessage }) { Skill Creator ) : null} -
+
{text}
@@ -99,7 +99,7 @@ interface MessagePartViewProps { function MessagePartView({ onContinue, part, threadId }: MessagePartViewProps) { if (part.type === "text") { return ( -
+
{part.text}
); diff --git a/apps/web/src/components/chat/message-timeline.ts b/apps/web/src/components/chat/message-timeline.ts index 708d5ace..c7f05841 100644 --- a/apps/web/src/components/chat/message-timeline.ts +++ b/apps/web/src/components/chat/message-timeline.ts @@ -42,6 +42,7 @@ export function isHiddenTranscriptPart(part: MessagePart): boolean { return ( part.type === "data-artifact" || part.type === "data-sandbox-status" || + /* retained for historical transcripts */ part.type === "data-plan" || part.type === "data-task-status" || part.type === "data-transcript-fragment" diff --git a/apps/web/src/components/chat/prompt-composer-controller.ts b/apps/web/src/components/chat/prompt-composer-controller.ts index a737e054..9e57c4bb 100644 --- a/apps/web/src/components/chat/prompt-composer-controller.ts +++ b/apps/web/src/components/chat/prompt-composer-controller.ts @@ -13,10 +13,6 @@ import { useState, } from "react"; import type { RunStatus } from "@/components/chat/status-pill"; -import { - type PromptAttachments, - usePromptAttachments, -} from "@/components/chat/use-prompt-attachments"; import { composePromptWithComposerContext } from "@/components/composer/composer-context-chips"; import type { ComposerMenuItem } from "@/components/composer/composer-popover"; import { useProjectFileItems } from "@/components/composer/project-file-source"; @@ -26,6 +22,10 @@ import { type TriggerDetector, useComposerTriggers, } from "@/components/composer/use-composer-triggers"; +import { + type ProjectFileUploads, + useProjectFileUploads, +} from "@/components/composer/use-project-file-uploads"; import { listUserSkills, USER_SKILLS_QUERY } from "@/lib/api/skills"; import { detectMentionToken, detectSlashToken } from "@/lib/input/caret-tokens"; import { useAppStore } from "@/lib/store/app-store"; @@ -72,7 +72,7 @@ interface PromptComposerActions { export interface PromptComposerController { actions: PromptComposerActions; - attachments: PromptAttachments; + attachments: ProjectFileUploads; meta: { textareaRef: RefObject }; state: PromptComposerState; triggers: ComposerTriggers; @@ -92,7 +92,7 @@ export function usePromptComposerController(props: PromptComposerProps): PromptC textareaRef, value: props.value, }); - const attachments = usePromptAttachments({ + const attachments = useProjectFileUploads({ getToken, latestValueRef: publisher.latestValueRef, onChange: publisher.publishValue, @@ -225,7 +225,7 @@ function useProjectSelection(project: ProjectSummary | null) { } type PromptComposerAssemblyOptions = { - attachments: PromptAttachments; + attachments: ProjectFileUploads; computerOpen: boolean; isRunning: boolean; menu: ReturnType; diff --git a/apps/web/src/components/chat/prompt-composer.tsx b/apps/web/src/components/chat/prompt-composer.tsx deleted file mode 100644 index 1684023b..00000000 --- a/apps/web/src/components/chat/prompt-composer.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { - type PromptComposerProps, - usePromptComposerController, -} from "@/components/chat/prompt-composer-controller"; -import { PromptComposerView } from "@/components/chat/prompt-composer-view"; - -export function PromptComposer(props: PromptComposerProps) { - const controller = usePromptComposerController(props); - return ; -} diff --git a/apps/web/src/components/chat/use-prompt-attachments.ts b/apps/web/src/components/chat/use-prompt-attachments.ts deleted file mode 100644 index 6359ed87..00000000 --- a/apps/web/src/components/chat/use-prompt-attachments.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - type ProjectFileUploads as PromptAttachments, - useProjectFileUploads as usePromptAttachments, -} from "@/components/composer/use-project-file-uploads"; diff --git a/apps/web/src/components/home/home-workspace.tsx b/apps/web/src/components/home/home-workspace.tsx index f16925ef..4763d051 100644 --- a/apps/web/src/components/home/home-workspace.tsx +++ b/apps/web/src/components/home/home-workspace.tsx @@ -10,7 +10,7 @@ import { HomeSessionChrome } from "@/components/home/home-session-chrome"; import { HomeSidebarOffset } from "@/components/home/home-sidebar-offset"; import { AppSidebar } from "@/components/shell/app-sidebar"; import { SidebarContentFrame } from "@/components/shell/sidebar-content-frame"; -import { CheatcodeCursorField } from "@/components/ui/cheatcode-cursor-field"; +import { CheatcodeCursorTrail } from "@/components/ui/cheatcode-cursor-field"; import { CheatcodeMark } from "@/components/ui/cheatcode-mark"; import { WorkspaceRunLayout } from "@/components/workspace/workspace-run-layout"; import { useAppStore } from "@/lib/store/app-store"; @@ -85,7 +85,7 @@ function HomeContentPane({ computerOpen }: { computerOpen: boolean }) {
- +
& { children: ReactNode; - onLaunch?: (() => void) | undefined; prompt: string; query?: Readonly> | undefined; }; @@ -18,7 +17,6 @@ type PromptLaunchButtonProps = Omit< /** Navigates with an opaque, one-time session key so prompt text never enters the URL. */ export function PromptLaunchButton({ children, - onLaunch, prompt, query, ...buttonProps @@ -37,7 +35,6 @@ export function PromptLaunchButton({ } } params.set("promptKey", createPromptHandoff(prompt).promptKey); - onLaunch?.(); router.push(`/?${params.toString()}`); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not open that prompt."); diff --git a/apps/web/src/components/preview/booting-computer.tsx b/apps/web/src/components/preview/booting-computer.tsx deleted file mode 100644 index 80d2ee2d..00000000 --- a/apps/web/src/components/preview/booting-computer.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { CheatcodeLoader } from "@/components/ui/cheatcode-loader"; - -/** Bare brand-mark loading state for sandbox and preview cold starts. */ -export function BootingComputer({ label = "Booting computer" }: { label?: string }) { - return ( - - ); -} diff --git a/apps/web/src/components/preview/preview-address-bar.tsx b/apps/web/src/components/preview/preview-address-bar.tsx deleted file mode 100644 index e0d913ad..00000000 --- a/apps/web/src/components/preview/preview-address-bar.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { PreviewDeviceMenu } from "@/components/preview/preview-device-menu"; -import { PreviewPathInput } from "@/components/preview/preview-path-input"; - -export function PreviewAddressBar({ previewUrl }: { previewUrl: string | null }) { - return ( -
- - -
- ); -} diff --git a/apps/web/src/components/preview/preview-side-panel.tsx b/apps/web/src/components/preview/preview-side-panel.tsx index 4b3583b1..82053379 100644 --- a/apps/web/src/components/preview/preview-side-panel.tsx +++ b/apps/web/src/components/preview/preview-side-panel.tsx @@ -5,6 +5,7 @@ import { Monitor, Smartphone } from "@cheatcode/ui"; import { useAuth } from "@clerk/nextjs"; import { QRCodeSVG } from "qrcode.react"; import { Activity, type ReactNode, useEffect } from "react"; +import { CheatcodeLoader } from "@/components/ui/cheatcode-loader"; import { CheatcodeTooltip } from "@/components/ui/cheatcode-tooltip"; import { RecoveryCard } from "@/components/ui/recovery-card"; import { PreviewSessionRefresh, useStablePreviewSource } from "@/lib/preview/preview-session"; @@ -14,7 +15,6 @@ import type { PreviewDevice, PreviewTab } from "@/lib/store/app-store"; import { useAppStore } from "@/lib/store/app-store"; import { emitFirstPreviewOpened } from "@/lib/telemetry/user-events"; import { cn } from "@/lib/ui/cn"; -import { BootingComputer } from "./booting-computer"; import { ComputerPanelTabs } from "./computer-panel-tabs"; import { ComputerSurfaceFrame } from "./computer-surface-frame"; import { ComputerToggleButton } from "./computer-toggle-button"; @@ -351,7 +351,12 @@ function AppTab({ !hasProject && (sandboxStatus === "cold" || sandboxStatus === "starting") ) { - return ; + return ( + + ); } return ( @@ -413,7 +418,12 @@ function AppTabContent({ return ( } + content={ + + } /> ); } diff --git a/apps/web/src/components/preview/preview-url-bar.tsx b/apps/web/src/components/preview/preview-url-bar.tsx index 39dbdf1b..efeff384 100644 --- a/apps/web/src/components/preview/preview-url-bar.tsx +++ b/apps/web/src/components/preview/preview-url-bar.tsx @@ -1,4 +1,5 @@ -import { PreviewAddressBar } from "@/components/preview/preview-address-bar"; +import { PreviewDeviceMenu } from "@/components/preview/preview-device-menu"; +import { PreviewPathInput } from "@/components/preview/preview-path-input"; import { PreviewExternalLink, PreviewNavigationControls, @@ -13,7 +14,10 @@ export function PreviewUrlBar({ previewUrl }: { previewUrl: string | null }) {
- +
+ + +
diff --git a/apps/web/src/components/shell/sidebar-expanded-navigation.tsx b/apps/web/src/components/shell/sidebar-expanded-navigation.tsx index 3dbfc030..d7fbf974 100644 --- a/apps/web/src/components/shell/sidebar-expanded-navigation.tsx +++ b/apps/web/src/components/shell/sidebar-expanded-navigation.tsx @@ -208,7 +208,7 @@ function SidebarHelpCard({ item, pathname }: { item: NavItem; pathname: string } {item.label} - {item.description ?? "Learn what Cheatcode can do"} + Learn what Cheatcode can do diff --git a/apps/web/src/components/ui/cheatcode-cursor-field.tsx b/apps/web/src/components/ui/cheatcode-cursor-field.tsx index 8318b6b8..98f6b40f 100644 --- a/apps/web/src/components/ui/cheatcode-cursor-field.tsx +++ b/apps/web/src/components/ui/cheatcode-cursor-field.tsx @@ -20,32 +20,13 @@ const CURSOR_IMAGE_SIZE = 20; const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, summary, [role="button"], [contenteditable="true"], [data-cheatcode-ignore]'; -type FieldVariant = "home" | "loading"; - -type CheatcodeCursorFieldProps = { - className?: string | undefined; - variant?: FieldVariant | undefined; -}; - type Point = { x: number; y: number; }; /** Runs Cheatcode's cursor-trail interaction while preserving the native cursor. */ -export function CheatcodeCursorField({ className, variant = "home" }: CheatcodeCursorFieldProps) { - return ( - - ); -} - -function CheatcodeCursorTrail({ - className, - restrictToWhitespace, -}: { - className?: string | undefined; - restrictToWhitespace: boolean; -}) { +export function CheatcodeCursorTrail({ className }: { className?: string | undefined }) { const surfaceRef = useRef(null); const imageRefs = useRef>([]); const nextImageRef = useRef(0); @@ -65,13 +46,13 @@ function CheatcodeCursorTrail({ }).current; useEffect(() => setIsMounted(true), []); - useExactTrailMotion(motionRefs, restrictToWhitespace); + useExactTrailMotion(motionRefs); return (