diff --git a/.env.example b/.env.example index de5d53cc..c6ba5b52 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,14 @@ # Run `pnpm dev:setup` to create the permission-restricted .env.local file. These -# neutral placeholders document the contract; do not hand-edit pooler URLs when +# neutral placeholders document the contract; do not hand-edit runtime URLs when # the wizard can assemble and validate them for you. -# Supabase session pooler only: *.pooler.supabase.com, port 5432, /postgres, -# and the role-qualified username .. A free dedicated +# Supabase Direct endpoint only: db..supabase.co, port 5432, +# /postgres, and the least-privilege role username. Hyperdrive owns pooling. A free dedicated # Supabase project is sufficient. Administrative credentials stay in # .env.migrate and never enter the application environment. -SUPABASE_GATEWAY_DATABASE_URL=postgresql://app_gateway.:@.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true -SUPABASE_AGENT_DATABASE_URL=postgresql://app_agent.:@.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true -SUPABASE_WEBHOOKS_DATABASE_URL=postgresql://app_webhooks.:@.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true +SUPABASE_GATEWAY_DATABASE_URL=postgresql://app_gateway:@db..supabase.co:5432/postgres?sslmode=require&uselibpqcompat=true +SUPABASE_AGENT_DATABASE_URL=postgresql://app_agent:@db..supabase.co:5432/postgres?sslmode=require&uselibpqcompat=true +SUPABASE_WEBHOOKS_DATABASE_URL=postgresql://app_webhooks:@db..supabase.co:5432/postgres?sslmode=require&uselibpqcompat=true # Clerk development instance. Production keys are rejected locally. Configure # the Clerk session token to expose metadata={{user.public_metadata}}. diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 6f87af37..d4d40e70 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -50,6 +50,7 @@ jobs: run: >- pnpm exec turbo run build --filter=@cheatcode/agent-worker + --filter=@cheatcode/artifact-worker --filter=@cheatcode/webhooks-worker --filter=@cheatcode/gateway-worker --filter=@cheatcode/preview-proxy @@ -58,6 +59,7 @@ jobs: run: | set -Eeuo pipefail configs=( + apps/artifact-worker/wrangler.jsonc apps/agent-worker/wrangler.jsonc apps/webhooks-worker/wrangler.jsonc apps/preview-proxy/wrangler.jsonc diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml index 6177a49c..1415aab8 100644 --- a/.github/workflows/static-checks.yml +++ b/.github/workflows/static-checks.yml @@ -245,6 +245,9 @@ jobs: if: needs.changes.outputs.root_code == 'true' run: pnpm typecheck:scripts + - name: Check Worker performance budgets + run: pnpm worker:performance-budgets + - name: Check affected architecture boundaries if: steps.workspace-scope.outputs.directories != '[]' env: diff --git a/.gitignore b/.gitignore index 639957bc..87860967 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ .wrangler/ .next/ .vercel/ +.terraform/ +*.tfstate +*.tfstate.* dist/ .qa/ *.tsbuildinfo diff --git a/README.md b/README.md index 4a5e81a7..d6849d2b 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,9 @@ different cookie site from `localhost`. Supabase is the only supported Postgres topology. Cheatcode does not start a local database, use Supabase Storage or Realtime, or expose runtime Workers to -an administrative credential. Each Worker connects through the shared Supabase -session pooler with its own `app_gateway`, `app_agent`, or `app_webhooks` login. +an administrative credential. Each database-backed Worker connects through +Hyperdrive to the Supabase Direct endpoint with its own `app_gateway`, +`app_agent`, or `app_webhooks` login. Before setup, publish an immutable Daytona snapshot from [`infra/containers/sandbox`](infra/containers/sandbox). The protected workflow @@ -120,7 +121,7 @@ or follow the container README to build the same sandbox image in your own Daytona environment. Enter the resulting immutable snapshot name in `DAYTONA_SANDBOX_SNAPSHOT`; setup will not inherit the hosted project's value. -Production deployments use Vercel for `apps/web` and Cloudflare for the four +Production deployments use Vercel for `apps/web` and Cloudflare for the five Workers. Review each app's `wrangler.jsonc`, `apps/web/vercel.json`, and the deployment workflows before changing that topology. Runtime provider keys are BYOK and must continue through `packages/byok`. diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index dc3914ee..41eab457 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -29,12 +29,13 @@ the exact R2 object again before returning. Terminal run persistence records upload quiescence only after the Workflow-owned tool steps have settled, while deletion RPCs terminate the run's Workflow before removing its durable state. -Artifact messages persist only the output UUID and presentation metadata. The authenticated -`POST /v1/outputs/:outputId/download-url` path rechecks tenant ownership, retention, and R2 -existence before minting a one-hour HMAC capability; the public signed download route is only the -streaming second hop. That route forwards single HTTP byte ranges to R2 and returns `206` metadata, -so browser media previews seek and start without downloading the entire artifact. Expiring -capabilities and internal R2 keys are never stored in transcripts or returned by artifact tools. +Artifact messages persist only the output UUID and presentation metadata. The lightweight +Artifact Worker owns the authenticated `POST /v1/outputs/:outputId/download-url` path and public +signed streaming hop; it rechecks the same tenant ownership, retention, and R2 existence through +the `app_agent` database role before minting a one-hour HMAC capability. The streaming hop forwards +single HTTP byte ranges to R2 and returns `206` metadata so browser media previews seek and start +without downloading the entire artifact. Expiring capabilities and internal R2 keys are never +stored in transcripts or returned by artifact tools. Browser screenshots use the same crash-consistent R2 persistence but are classified as internal tool evidence. Their reserved filenames keep them out of project-file and slash-command catalogs, @@ -337,7 +338,8 @@ and it does not apply per-run or daily dollar caps. Provider usage remains an opaque SDK concern. AgentRun writes Workers Analytics Engine agent-run metrics on terminal statuses and emits -a first-visible-chunk TTFT performance metric. Run +separate first-status, first-model-text, final-token, run-completion, and Workflow-acceptance +performance metrics. The legacy TTFT column aliases first model text only. Run admission events carry the planned logical model, while stream-attempt/completion events carry the resolved logical model. A failure before any stream attempt keeps planned attribution instead; provider-local transport IDs remain structured-log context. R2-backed artifact diff --git a/apps/agent-worker/src/agent-api-system-routes.ts b/apps/agent-worker/src/agent-api-system-routes.ts index 56a31f98..4c2796ea 100644 --- a/apps/agent-worker/src/agent-api-system-routes.ts +++ b/apps/agent-worker/src/agent-api-system-routes.ts @@ -1,13 +1,6 @@ -import { findGeneratedOutput, getProject, withUserDb } from "@cheatcode/db"; -import { previewHostnameForWorker, resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env"; +import { getProject, withUserDb } from "@cheatcode/db"; import { APIError } from "@cheatcode/observability"; -import { - OutputIdSchema, - type ProjectId, - toProjectId, - toUserId, - type UserId, -} from "@cheatcode/types"; +import { type ProjectId, toProjectId, toUserId, type UserId } from "@cheatcode/types"; import { AGENT_FORWARD_ROUTES, InternalAgentStateDeleteBodySchema, @@ -25,25 +18,13 @@ import { sandboxStubForUser, } from "./agent-routing"; import { isAgentStateDeletionAuthorized } from "./agent-state-deletion-policy"; -import { - createOutputDownloadCapability, - OutputDownloadQuerySchema, - verifySignedOutputDownload, -} from "./output-download"; import { readGatewayUserId } from "./tenancy"; const RUN_STATE_DELETE_CONCURRENCY = 16; type AgentContext = Context<{ Bindings: AgentEnv }>; export function registerAgentSystemHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { - const coreRoutes = AGENT_FORWARD_ROUTES.core; const projectRoute = AGENT_FORWARD_ROUTES.project.downloadProject; - app.on( - coreRoutes.mintOutputDownloadUrl.method, - coreRoutes.mintOutputDownloadUrl.path, - mintOutputDownloadUrl, - ); - app.on(coreRoutes.downloadOutput.method, coreRoutes.downloadOutput.path, downloadOutput); app.on(projectRoute.method, projectRoute.path, downloadProjectArchive); } @@ -125,157 +106,6 @@ function deletedStateResult(): InternalStateDeleteResponse { return InternalStateDeleteResponseSchema.parse({ ok: true }); } -async function mintOutputDownloadUrl(c: AgentContext): Promise { - const outputId = parseOutputId(c.req.param("outputId")); - const userId = toUserId(readGatewayUserId(c.req.raw.headers)); - const output = await findDownloadableOutput(c.env, outputId, userId); - if (!(await c.env.R2_OUTPUTS.head(output.r2Key))) { - throw new APIError(404, "resource_output_not_found", "Output object not found", { - retriable: false, - }); - } - const capability = await createOutputDownloadCapability({ - baseUrl: outputDownloadBaseUrl(c.env), - outputId, - secret: await resolveOutputSigningSecret(c.env.OUTPUT_DOWNLOAD_SIGNING_SECRET), - userId, - }); - const response = c.json(capability); - response.headers.set("Cache-Control", "private, max-age=0, no-store"); - response.headers.set("Referrer-Policy", "no-referrer"); - return response; -} - -async function downloadOutput(c: AgentContext): Promise { - const outputId = parseOutputId(c.req.param("outputId")); - const query = parseOutputDownloadQuery(c); - const isValid = await verifySignedOutputDownload({ - expires: query.expires, - outputId, - secret: await resolveOutputSigningSecret(c.env.OUTPUT_DOWNLOAD_SIGNING_SECRET), - signature: query.sig, - userId: query.userId, - }); - if (!isValid) { - throw new APIError(403, "permission_access_denied", "Invalid or expired output download URL", { - retriable: false, - }); - } - const output = await findDownloadableOutput(c.env, outputId, query.userId); - const object = await c.env.R2_OUTPUTS.get(output.r2Key, { - range: c.req.raw.headers, - }); - if (!object?.body) { - throw new APIError(404, "resource_output_not_found", "Output object not found", { - retriable: false, - }); - } - const headers = outputDownloadHeaders(output, object); - return new Response(object.body, { - headers, - status: object.range ? 206 : 200, - }); -} - -function outputDownloadHeaders( - output: { filename: string; mimeType: string }, - object: R2ObjectBody, -): Headers { - const headers = new Headers({ - "Accept-Ranges": "bytes", - "Cache-Control": "private, max-age=0, no-store", - "Content-Disposition": downloadContentDisposition(output.filename), - "Content-Type": output.mimeType, - "Cross-Origin-Resource-Policy": "cross-origin", - ETag: object.httpEtag, - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - }); - const range = resolveOutputRange(object.range, object.size); - headers.set("Content-Length", String(range?.length ?? object.size)); - if (range) { - headers.set("Content-Range", `bytes ${range.offset}-${range.end}/${object.size}`); - } - return headers; -} - -function resolveOutputRange( - range: R2Range | undefined, - objectSize: number, -): { end: number; length: number; offset: number } | undefined { - if (!range) return undefined; - if ("suffix" in range && typeof range.suffix === "number") { - const length = Math.min(range.suffix, objectSize); - const offset = objectSize - length; - return { end: objectSize - 1, length, offset }; - } - const offset = "offset" in range && typeof range.offset === "number" ? range.offset : 0; - const length = "length" in range && typeof range.length === "number" ? range.length : undefined; - const boundedLength = Math.min(length ?? objectSize - offset, objectSize - offset); - return { end: offset + boundedLength - 1, length: boundedLength, offset }; -} - -function parseOutputId(value: string | undefined): string { - const parsed = OutputIdSchema.safeParse(value); - if (!parsed.success) { - throw new APIError(400, "request_path_param_invalid", "Invalid output id", { - details: { issues: parsed.error.issues.map((issue) => issue.message) }, - retriable: false, - }); - } - return parsed.data; -} - -function parseOutputDownloadQuery(c: AgentContext): z.infer { - const parsed = OutputDownloadQuerySchema.safeParse({ - expires: c.req.query("expires"), - sig: c.req.query("sig"), - userId: c.req.query("userId"), - }); - if (!parsed.success) { - throw new APIError(400, "request_query_param_invalid", "Invalid output download signature", { - details: { issues: parsed.error.issues.map((issue) => issue.message) }, - retriable: false, - }); - } - return parsed.data; -} - -async function findDownloadableOutput(env: AgentEnv, outputId: string, userId: UserId) { - return withUserDb(env, userId, async ({ transaction }) => { - const output = await transaction((tx) => findGeneratedOutput(tx, { outputId, userId })); - if (!output) { - throw new APIError(404, "resource_output_not_found", "Output not found", { - retriable: false, - }); - } - return output; - }); -} - -async function resolveOutputSigningSecret(secret: WorkerSecret): Promise { - try { - return await resolveWorkerSecret(secret); - } catch { - throw new APIError( - 503, - "service_maintenance_unavailable", - "Output signing secret is unavailable", - { - retriable: true, - }, - ); - } -} - -function outputDownloadBaseUrl(env: AgentEnv): string | undefined { - const previewHostname = previewHostnameForWorker(env.CHEATCODE_ENVIRONMENT, env.PREVIEW_HOSTNAME); - if (previewHostname === "localhost:8787" || previewHostname === "127.0.0.1:8787") { - return `http://${previewHostname}`; - } - return env.OUTPUT_DOWNLOAD_BASE_URL; -} - async function downloadProjectArchive(c: AgentContext): Promise { const parsedProjectId = z.string().uuid().safeParse(c.req.param("projectId")); if (!parsedProjectId.success) { diff --git a/apps/agent-worker/src/agent-env.ts b/apps/agent-worker/src/agent-env.ts index c477ecb9..c80d1f0c 100644 --- a/apps/agent-worker/src/agent-env.ts +++ b/apps/agent-worker/src/agent-env.ts @@ -21,8 +21,6 @@ export interface AgentEnv extends AnalyticsBindings { DAYTONA_WORKSPACE_VOLUME: string; HYPERDRIVE: Hyperdrive; MORPH_API_KEY: WorkerSecret; - OUTPUT_DOWNLOAD_BASE_URL?: string; - OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecret; PREVIEW_TOKEN_SECRET: WorkerSecret; PREVIEW_HOSTNAME?: string; PROJECT_SANDBOX: DurableObjectNamespace; diff --git a/apps/agent-worker/src/agent-routing.ts b/apps/agent-worker/src/agent-routing.ts index 63d83f3d..dfe34748 100644 --- a/apps/agent-worker/src/agent-routing.ts +++ b/apps/agent-worker/src/agent-routing.ts @@ -19,6 +19,7 @@ import { toAgentRunId, toProjectId, toThreadId, toUserId, type UserId } from "@c import type { CreateRun, ProjectSummary } from "@cheatcode/types/api"; import { QUOTA_FEATURES } from "@cheatcode/types/quota"; import type { AgentEnv } from "./agent-env"; +import { durableObjectLocationHint } from "./durable-object-location"; 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"; @@ -63,7 +64,9 @@ export async function sandboxForUser( async function sandboxIdentityForUser(env: AgentEnv, userId: string) { const sandboxName = await userSandboxName(userId); return { - sandbox: env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(sandboxName)), + sandbox: env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(sandboxName), { + locationHint: durableObjectLocationHint(env.DAYTONA_TARGET), + }), sandboxName, }; } @@ -159,7 +162,9 @@ export async function requireProjectAccess( } export function agentRunForRunId(env: AgentEnv, runId: string): DurableObjectStub { - return env.AGENT_RUN.get(env.AGENT_RUN.idFromName(runId)); + return env.AGENT_RUN.get(env.AGENT_RUN.idFromName(runId), { + locationHint: durableObjectLocationHint(env.DAYTONA_TARGET), + }); } interface StartAgentRunInput { diff --git a/apps/agent-worker/src/durable-object-location.ts b/apps/agent-worker/src/durable-object-location.ts new file mode 100644 index 00000000..e0131fec --- /dev/null +++ b/apps/agent-worker/src/durable-object-location.ts @@ -0,0 +1,11 @@ +import { DEFAULT_DAYTONA_TARGET } from "@cheatcode/env"; + +/** Best-effort first-instantiation hint aligned with the configured sandbox region. */ +export function durableObjectLocationHint(target: string | undefined): DurableObjectLocationHint { + const normalized = (target ?? DEFAULT_DAYTONA_TARGET).trim().toLowerCase(); + if (/^(?:eu|europe|eu-)/u.test(normalized)) return "weur"; + if (/^(?:apac|asia|sg|singapore|jp|japan|kr|korea|in|india)/u.test(normalized)) return "apac"; + if (/^(?:au|australia|oc)/u.test(normalized)) return "oc"; + if (/^(?:me|middle-east)/u.test(normalized)) return "me"; + return "enam"; +} 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 b0e1a7e7..252a568a 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-env.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-env.ts @@ -10,11 +10,10 @@ export interface AgentRunEnv extends AnalyticsBindings { CHEATCODE_RELEASE_SHA?: string; COMPOSIO_API_KEY?: WorkerSecret; DATABASE_CONTEXT_SIGNING_SECRET_AGENT: WorkerSecret; + DAYTONA_TARGET?: string; DEEPSEEK_PLATFORM_API_KEY?: WorkerSecret; HYPERDRIVE: Hyperdrive; MORPH_API_KEY: WorkerSecret; - OUTPUT_DOWNLOAD_BASE_URL?: string; - OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecret; PREVIEW_HOSTNAME?: string; PROJECT_SANDBOX: DurableObjectNamespace; QUOTA_TRACKER: QuotaTrackerNamespace; diff --git a/apps/agent-worker/src/durable-objects/agent-run-output.ts b/apps/agent-worker/src/durable-objects/agent-run-output.ts index b9a49d61..3d3359a8 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-output.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-output.ts @@ -6,7 +6,7 @@ import { } from "../streaming/ui-message-stream"; import { emitRunAbandoned } from "./agent-run-abandonment"; import type { AgentRunEnv } from "./agent-run-env"; -import { emitFirstVisibleChunkMetric } from "./agent-run-performance"; +import { emitRunChunkPerformanceMetrics } from "./agent-run-performance"; import { appendAgentRunMessagePart, appendAgentRunMessagePartOnce, @@ -123,7 +123,7 @@ export class AgentRunOutput { private broadcast(chunk: UIMessageChunk, seq: number): void { const sequencedChunk = { chunk, seq }; - emitFirstVisibleChunkMetric(this.options.ctx, this.options.env, chunk); + emitRunChunkPerformanceMetrics(this.options.ctx, this.options.env, chunk); for (const subscriber of [...this.subscribers]) { if ((subscriber.controller.desiredSize ?? 1) <= 0) { this.errorSubscriber(subscriber, new Error("Agent stream subscriber fell behind.")); diff --git a/apps/agent-worker/src/durable-objects/agent-run-performance.ts b/apps/agent-worker/src/durable-objects/agent-run-performance.ts index 835dd3d1..ca3f4fea 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-performance.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-performance.ts @@ -1,31 +1,102 @@ -import { type AnalyticsBindings, emitPerformanceMetric } from "@cheatcode/observability"; +import { + type AnalyticsBindings, + emitPerformanceMetric, + type PerformanceMetric, +} from "@cheatcode/observability"; import type { UIMessageChunk } from "ai"; import { getRunStateValue, readStoredRunSnapshot, setRunStateValue } from "./agent-run-storage"; -const FIRST_VISIBLE_METRIC_KEY = "first_visible_metric_emitted"; +const METRIC_KEYS = { + finalToken: "final_token_metric_emitted", + firstModelText: "first_model_text_metric_emitted", + firstStatus: "first_status_metric_emitted", + runCompletion: "run_completion_metric_emitted", +} as const; -export function emitFirstVisibleChunkMetric( +interface RunPerformanceEnv extends AnalyticsBindings { + CF_VERSION_METADATA?: { id: string }; + CHEATCODE_ENVIRONMENT?: string; + CHEATCODE_RELEASE_SHA?: string; +} + +export function emitRunChunkPerformanceMetrics( ctx: DurableObjectState, - env: AnalyticsBindings, + env: RunPerformanceEnv, chunk: UIMessageChunk, now = Date.now, ): void { - if (getRunStateValue(ctx, FIRST_VISIBLE_METRIC_KEY) === "true" || !isVisibleChunk(chunk)) { - return; - } - const startedAt = readStoredRunSnapshot(ctx)?.startedAt ?? now(); + if (isFirstStatusChunk(chunk)) emitOnce(ctx, env, "firstStatus", now); + if (isFirstModelTextChunk(chunk)) emitOnce(ctx, env, "firstModelText", now); + if (chunk.type === "text-end") emitOnce(ctx, env, "finalToken", now); + if (chunk.type === "finish") emitOnce(ctx, env, "runCompletion", now); +} + +export function emitWorkflowAcceptanceMetric( + ctx: DurableObjectState, + env: RunPerformanceEnv, + durationMs: number, +): void { + const key = "workflow_acceptance_metric_emitted"; + if (getRunStateValue(ctx, key) === "true") return; + const snapshot = readStoredRunSnapshot(ctx); + emitPerformanceMetric(env, { + ...runDimensions(env), + ...(snapshot ? { logicalModelId: snapshot.modelId } : {}), + ...(snapshot ? { provider: modelProvider(snapshot.modelId) } : {}), + route: "/internal/runs/start", + statusClass: "accepted", + workerName: "agent", + workflowAcceptanceMs: Math.max(0, durationMs), + }); + setRunStateValue(ctx, key, "true"); +} + +function emitOnce( + ctx: DurableObjectState, + env: RunPerformanceEnv, + phase: keyof typeof METRIC_KEYS, + now: () => number, +): void { + const key = METRIC_KEYS[phase]; + if (getRunStateValue(ctx, key) === "true") return; + const snapshot = readStoredRunSnapshot(ctx); + const durationMs = Math.max(0, now() - (snapshot?.startedAt ?? now())); emitPerformanceMetric(env, { + ...runDimensions(env), + ...(phase === "finalToken" ? { finalTokenMs: durationMs } : {}), + ...(phase === "firstModelText" ? { firstModelTextMs: durationMs, ttftMs: durationMs } : {}), + ...(phase === "firstStatus" ? { firstStatusMs: durationMs } : {}), + ...(phase === "runCompletion" ? { runCompletionMs: durationMs } : {}), + ...(snapshot ? { logicalModelId: snapshot.modelId } : {}), + ...(snapshot ? { provider: modelProvider(snapshot.modelId) } : {}), route: "/internal/runs/start", statusClass: "streaming", - ttftMs: Math.max(0, now() - startedAt), workerName: "agent", }); - setRunStateValue(ctx, FIRST_VISIBLE_METRIC_KEY, "true"); + setRunStateValue(ctx, key, "true"); +} + +function runDimensions(env: RunPerformanceEnv): Pick { + const versionTag = env.CF_VERSION_METADATA?.id ?? env.CHEATCODE_RELEASE_SHA; + return { + ...(env.CHEATCODE_ENVIRONMENT ? { envTag: env.CHEATCODE_ENVIRONMENT } : {}), + ...(versionTag ? { versionTag } : {}), + }; +} + +function modelProvider(modelId: string): string { + return modelId.slice(0, modelId.indexOf("/")); +} + +function isFirstStatusChunk(chunk: UIMessageChunk): boolean { + return chunk.type === "data-sandbox-status" || chunk.type === "data-error"; } -function isVisibleChunk(chunk: UIMessageChunk): boolean { - if (chunk.type === "text-delta") { - return chunk.delta.trim().length > 0; - } - return chunk.type === "data-error" || chunk.type === "data-sandbox-status"; +function isFirstModelTextChunk(chunk: UIMessageChunk): boolean { + if (chunk.type === "text-delta") return chunk.delta.trim().length > 0; + if (chunk.type !== "data-model-provisional") return false; + const data = (Object(chunk) as Record)["data"]; + if (!data || typeof data !== "object") return false; + const record = data as Record; + return record["phase"] === "delta" && String(record["delta"] ?? "").trim().length > 0; } diff --git a/apps/agent-worker/src/durable-objects/agent-run-provisional.ts b/apps/agent-worker/src/durable-objects/agent-run-provisional.ts new file mode 100644 index 00000000..dece3a68 --- /dev/null +++ b/apps/agent-worker/src/durable-objects/agent-run-provisional.ts @@ -0,0 +1,63 @@ +import type { UIMessageChunk } from "ai"; + +const PROVISIONAL_BATCH_MAX_CHARACTERS = 4_096; +const PROVISIONAL_BATCH_MAX_DELAY_MS = 80; + +export function provisionalModelResetChunk(): UIMessageChunk { + return { + data: { phase: "reset", v: 1 }, + transient: true, + type: "data-model-provisional", + } as UIMessageChunk; +} + +/** Batches provider tokens into bounded, replayable Durable Object events. */ +export class ProvisionalModelBatcher { + private batchIndex = 0; + private buffer = ""; + private lastFlushAt = 0; + + public constructor( + private readonly streamId: string, + private readonly append: (eventKey: string, chunk: UIMessageChunk) => Promise, + ) {} + + public async push(delta: string): Promise { + if (delta.length === 0) return; + this.buffer += delta; + const now = Date.now(); + if ( + this.batchIndex === 0 || + this.buffer.length >= PROVISIONAL_BATCH_MAX_CHARACTERS || + now - this.lastFlushAt >= PROVISIONAL_BATCH_MAX_DELAY_MS + ) { + await this.flush(); + } + } + + public async flush(): Promise { + while (this.buffer.length > 0) { + const end = safeSliceEnd(this.buffer, PROVISIONAL_BATCH_MAX_CHARACTERS); + const delta = this.buffer.slice(0, end); + this.buffer = this.buffer.slice(end); + const batchIndex = this.batchIndex; + this.batchIndex += 1; + this.lastFlushAt = Date.now(); + await this.append(`model-provisional:${this.streamId}:${batchIndex}`, { + data: { delta, phase: "delta", streamId: this.streamId, v: 1 }, + transient: true, + type: "data-model-provisional", + } as UIMessageChunk); + } + } +} + +function safeSliceEnd(value: string, maxCharacters: number): number { + const candidate = Math.min(value.length, maxCharacters); + if (candidate === value.length) return candidate; + const previous = value.charCodeAt(candidate - 1); + const next = value.charCodeAt(candidate); + return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff + ? candidate - 1 + : candidate; +} diff --git a/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts b/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts index 3c7b2bd8..da70b478 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts @@ -23,6 +23,7 @@ import { } from "@cheatcode/types"; import type { UIMessageChunk } from "ai"; import { z } from "zod"; +import { durableObjectLocationHint } from "../durable-object-location"; import type { AgentRun } from "./agent-run"; import { storeAgentArtifact } from "./agent-run-artifacts"; import { loadThreadModelContext } from "./agent-run-conversation"; @@ -35,6 +36,7 @@ import { prepareMastraContext, } from "./agent-run-mastra-context"; import { finalizeAppBuilderRun, prepareAppBuilderRun } from "./agent-run-path"; +import { ProvisionalModelBatcher } from "./agent-run-provisional"; import { type StartRunInput, StartRunInputSchema } from "./agent-run-schemas"; import { guardSkillRuntimeCapabilities } from "./agent-run-skill-runtime"; import { AgentToolErrorOutputSchema, agentToolErrorOutput } from "./agent-run-tool-error-support"; @@ -155,6 +157,7 @@ export async function generateWorkflowModelStep( workflowInstanceId: string, payload: AgentRunWorkflowPayload, state: WorkflowAgentState, + execution: { stepIndex: number; workflowAttempt: number }, ): Promise { const input = structuredClone(state.input); const callback = workflowCallback(payload, workflowInstanceId); @@ -176,6 +179,7 @@ export async function generateWorkflowModelStep( logger, messages: state.messages, primary, + provisionalStreamId: provisionalStreamId(execution, 0), sandbox, stub, usesManagedPreview: state.appBuilderUsesManagedPreview, @@ -192,6 +196,7 @@ export async function generateWorkflowModelStep( logger, messages: state.messages, primary: fallback, + provisionalStreamId: provisionalStreamId(execution, 1), sandbox, stub, usesManagedPreview: state.appBuilderUsesManagedPreview, @@ -400,6 +405,7 @@ async function generateWithCredential(input: { logger: ReturnType; messages: WorkflowJsonValue[]; primary: LlmCredential; + provisionalStreamId: string; sandbox: ProjectSandboxStub; stub: DurableObjectStub; usesManagedPreview: boolean; @@ -417,25 +423,63 @@ async function generateWithCredential(input: { const options = runtime.mastraOptions(input.primary); const prepared = await prepareMastraContext(options); const requestContext = createAgentRequestContext(options, prepared); + const provisional = new ProvisionalModelBatcher(input.provisionalStreamId, (eventKey, chunk) => + appendProvisionalEvent(input, eventKey, chunk), + ); + const step = await generateStreamingModelStep(input, requestContext, provisional); + return WorkflowModelStepResultSchema.parse({ + input: input.input, + logicalModelId: input.primary.logicalModelId, + step, + }); +} + +async function generateStreamingModelStep( + input: Parameters[0], + requestContext: ReturnType, + provisional: ProvisionalModelBatcher, +) { const toolPolicy = resolveAgentToolPolicy({ projectMode: input.input.projectMode, ...(input.input.runIntent ? { runIntent: input.input.runIntent } : {}), ...(input.input.selectedTool ? { selectedTool: input.input.selectedTool } : {}), usesManagedPreview: input.usesManagedPreview, }); - const step = await generateGeneralAgentStep({ - abortSignal: AbortSignal.timeout(MODEL_STEP_TIMEOUT_MS), - ...toolPolicy, - isDeepSeek: input.primary.transportProvider === "deepseek", - messages: input.messages, - requestContext, - runId: input.input.runId, - }); - return WorkflowModelStepResultSchema.parse({ - input: input.input, - logicalModelId: input.primary.logicalModelId, - step, - }); + try { + return await generateGeneralAgentStep({ + abortSignal: AbortSignal.timeout(MODEL_STEP_TIMEOUT_MS), + ...toolPolicy, + isDeepSeek: input.primary.transportProvider === "deepseek", + messages: input.messages, + onTextDelta: (delta) => provisional.push(delta), + requestContext, + runId: input.input.runId, + }); + } finally { + await provisional.flush(); + } +} + +async function appendProvisionalEvent( + input: Parameters[0], + eventKey: string, + chunk: UIMessageChunk, +): Promise { + await requireCurrent( + await input.stub.appendWorkflowEvent({ + ...input.callback, + chunks: [chunk], + eventKey, + }), + "append provisional model event", + ); +} + +function provisionalStreamId( + execution: { stepIndex: number; workflowAttempt: number }, + providerAttempt: number, +): string { + return `model-${execution.stepIndex}-workflow-${execution.workflowAttempt}-provider-${providerAttempt}`; } function workflowRuntimeOptions(input: { @@ -569,7 +613,9 @@ async function restoreUploadedFiles( } function sandboxFor(env: AgentRunEnv, input: StartRunInput): ProjectSandboxStub { - return env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(input.sandboxName)); + return env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(input.sandboxName), { + locationHint: durableObjectLocationHint(env.DAYTONA_TARGET), + }); } function agentRunStub( 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 1e965bb6..78d7c1e5 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import type { AgentRun } from "./agent-run"; import type { AgentRunEnv } from "./agent-run-env"; import { toAgentRunStreamError } from "./agent-run-errors"; +import { provisionalModelResetChunk } from "./agent-run-provisional"; import { canonicalResearchReport, isResearchReportTool, @@ -290,13 +291,14 @@ async function executeModelWorkflowStep(input: { const serialized = await input.workflowStep.do( `generate model turn ${input.stepIndex}`, MODEL_STEP, - async () => + async (stepContext) => serializeWorkflowValue( await generateWorkflowModelStep( input.env, input.workflowInstanceId, input.payload, input.state, + { stepIndex: input.stepIndex, workflowAttempt: stepContext.attempt }, ), ), ); @@ -391,7 +393,7 @@ async function publishModelStep( model: WorkflowModelStepResult, stepIndex: number, ): Promise { - const chunks: UIMessageChunk[] = []; + const chunks: UIMessageChunk[] = [provisionalModelResetChunk()]; if (model.fallback) { chunks.push({ data: { ...model.fallback, v: 1 }, type: "data-model-fallback" }); } diff --git a/apps/agent-worker/src/durable-objects/agent-run.ts b/apps/agent-worker/src/durable-objects/agent-run.ts index 70fa846f..6ca97a6e 100644 --- a/apps/agent-worker/src/durable-objects/agent-run.ts +++ b/apps/agent-worker/src/durable-objects/agent-run.ts @@ -13,6 +13,8 @@ import { } from "./agent-run-message-persistence"; import { persistAgentRunLogicalModel } from "./agent-run-model-persistence"; import { AgentRunOutput } from "./agent-run-output"; +import { emitWorkflowAcceptanceMetric } from "./agent-run-performance"; +import { provisionalModelResetChunk } from "./agent-run-provisional"; import { absentAgentRunOkResponse, absentAgentRunWorkflowResponse, @@ -344,7 +346,8 @@ export class AgentRun extends DurableObject { this.setOwnerUserId(input.userId); this.setStatus("running"); }); - await this.workflow.admit(admission); + this.appendRunStart(input.runId); + this.scheduleWorkflowAdmission(admission); const stream = this.output.resume(0); if (!stream) { return agentRunStreamCapacityResponse(); @@ -366,7 +369,7 @@ export class AgentRun extends DurableObject { return agentRunStreamCapacityResponse(); } if (hasActiveRun(this.getStatus())) { - await this.workflow.admit(await this.workflow.createAdmission(input)); + this.scheduleWorkflowAdmission(await this.workflow.createAdmission(input)); } const stream = this.output.resume(0); if (!stream) { @@ -464,6 +467,7 @@ export class AgentRun extends DurableObject { createLogger().warn("agent_run_workflow_termination_failed", { error }); }); try { + await this.append(provisionalModelResetChunk(), { allowAfterCancelRequest: true }); await this.append( { type: "data-error", @@ -505,10 +509,7 @@ export class AgentRun extends DurableObject { const outcome = await this.workflow.authorizeCallback(input); if (outcome !== "current") return Response.json({ outcome }); setAgentRunStage(this.ctx, "Preparing project sandbox."); - this.output.appendWorkflowEvent("run:start", [ - { messageId: input.input.runId, type: "start" }, - { data: { status: "starting", v: 1 }, type: "data-sandbox-status" }, - ]); + this.appendRunStart(input.input.runId); await this.persistRunStatusById({ isArtifactsQuiesced: false, runId: input.input.runId, @@ -639,6 +640,7 @@ export class AgentRun extends DurableObject { message: string; retriable: boolean; }): Promise { + await this.append(provisionalModelResetChunk(), { allowAfterCancelRequest: true }); await this.append( { type: "data-error", @@ -673,6 +675,30 @@ export class AgentRun extends DurableObject { }); } + private appendRunStart(runId: string): void { + this.output.appendWorkflowEvent("run:start", [ + { messageId: runId, type: "start" }, + { data: { status: "starting", v: 1 }, type: "data-sandbox-status" }, + ]); + } + + private scheduleWorkflowAdmission( + admission: Awaited>, + ): void { + const startedAt = performance.now(); + this.ctx.waitUntil( + this.workflow + .admit(admission) + .then(() => emitWorkflowAcceptanceMetric(this.ctx, this.env, performance.now() - startedAt)) + .catch((error: unknown) => { + createLogger({ runId: admission.input.runId }).warn( + "agent_run_workflow_admission_deferred", + { error }, + ); + }), + ); + } + private getOwnerUserId(): string | undefined { return getRunStateValue(this.ctx, "owner_user_id"); } diff --git a/apps/agent-worker/src/index.ts b/apps/agent-worker/src/index.ts index 10b904c4..b4243a7a 100644 --- a/apps/agent-worker/src/index.ts +++ b/apps/agent-worker/src/index.ts @@ -46,6 +46,17 @@ agentApp.use( "*", createPerformanceMetricMiddleware>({ errorStatus: (error) => toAgentRouteError(error).status, + metricFields: (c) => { + const colo = requestColo(c.req.raw); + const placement = c.req.header("cf-placement"); + const versionTag = c.env.CF_VERSION_METADATA?.id ?? c.env.CHEATCODE_RELEASE_SHA; + return { + ...(colo ? { colo } : {}), + envTag: c.env.CHEATCODE_ENVIRONMENT, + ...(placement ? { placement } : {}), + ...(versionTag ? { versionTag } : {}), + }; + }, routeName: registeredRouteName, workerName: "agent", }), @@ -96,4 +107,9 @@ function registeredRouteName(c: Context<{ Bindings: AgentEnv }>): string { } } +function requestColo(request: Request): string | undefined { + const colo = request.cf?.colo; + return typeof colo === "string" ? colo : undefined; +} + export default agentHandler; diff --git a/apps/agent-worker/src/streaming/ui-message-stream.ts b/apps/agent-worker/src/streaming/ui-message-stream.ts index 4bd36155..353c87ea 100644 --- a/apps/agent-worker/src/streaming/ui-message-stream.ts +++ b/apps/agent-worker/src/streaming/ui-message-stream.ts @@ -44,7 +44,7 @@ export function createAgentStreamResponse(options: { status?: number; stream: ReadableStream; }): Response { - const headers = { "Cache-Control": "private, no-store" }; + const headers = { "Cache-Control": "private, no-store, no-transform" }; if (options.status === undefined) { return createUIMessageStreamResponse({ headers, stream: options.stream }); } diff --git a/apps/agent-worker/wrangler.jsonc b/apps/agent-worker/wrangler.jsonc index 177fcd3b..53abee2e 100644 --- a/apps/agent-worker/wrangler.jsonc +++ b/apps/agent-worker/wrangler.jsonc @@ -48,11 +48,6 @@ "store_id": "ba25994718db4707ab99a498e22eb5a6", "secret_name": "deepseek-platform-api-key" }, - { - "binding": "OUTPUT_DOWNLOAD_SIGNING_SECRET", - "store_id": "ba25994718db4707ab99a498e22eb5a6", - "secret_name": "output-download-signing-secret" - }, { "binding": "MORPH_API_KEY", "store_id": "ba25994718db4707ab99a498e22eb5a6", diff --git a/apps/artifact-worker/README.md b/apps/artifact-worker/README.md new file mode 100644 index 00000000..c778969d --- /dev/null +++ b/apps/artifact-worker/README.md @@ -0,0 +1,22 @@ +# Artifact Worker + +Lightweight private Cloudflare Worker for signed generated-output delivery. +The public URL remains on the gateway; this Worker is reachable only through +the gateway Service Binding. + +Authenticated minting verifies tenant ownership and object existence. Signed +downloads re-check tenant ownership, stream R2 bodies without buffering, honor +conditional and single-range requests through the R2 binding, and return ETag, +stored HTTP metadata, Content-Length, Accept-Ranges, and Content-Range headers. +All user outputs remain `private, no-store` and never enter a shared cache. + +The Worker uses the existing `app_agent` Hyperdrive role because generated +outputs are agent-owned records. It never writes output metadata or bytes. + +## Checks + +```bash +pnpm --filter @cheatcode/artifact-worker lint +pnpm --filter @cheatcode/artifact-worker typecheck +pnpm --filter @cheatcode/artifact-worker build +``` diff --git a/apps/artifact-worker/package.json b/apps/artifact-worker/package.json new file mode 100644 index 00000000..e22c4759 --- /dev/null +++ b/apps/artifact-worker/package.json @@ -0,0 +1,29 @@ +{ + "name": "@cheatcode/artifact-worker", + "private": true, + "license": "PolyForm-Noncommercial-1.0.0", + "version": "0.0.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "wrangler deploy --dry-run", + "lint": "biome check .", + "typecheck": "tsc -p tsconfig.json --noEmit --incremental false" + }, + "dependencies": { + "@cheatcode/auth": "workspace:*", + "@cheatcode/db": "workspace:*", + "@cheatcode/env": "workspace:*", + "@cheatcode/observability": "workspace:*", + "@cheatcode/types": "workspace:*", + "hono": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@cheatcode/tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:", + "typescript": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/artifact-worker/src/artifact-env.ts b/apps/artifact-worker/src/artifact-env.ts new file mode 100644 index 00000000..036e9ef3 --- /dev/null +++ b/apps/artifact-worker/src/artifact-env.ts @@ -0,0 +1,13 @@ +import type { CloudflareVersionMetadata, WorkerSecret } from "@cheatcode/env"; +import type { AnalyticsBindings } from "@cheatcode/observability"; + +export interface ArtifactEnv extends AnalyticsBindings { + CF_VERSION_METADATA?: CloudflareVersionMetadata; + CHEATCODE_ENVIRONMENT: "development" | "production"; + CHEATCODE_RELEASE_SHA?: string; + DATABASE_CONTEXT_SIGNING_SECRET_AGENT: WorkerSecret; + HYPERDRIVE: Hyperdrive; + OUTPUT_DOWNLOAD_BASE_URL?: string; + OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecret; + R2_OUTPUTS: R2Bucket; +} diff --git a/apps/artifact-worker/src/index.ts b/apps/artifact-worker/src/index.ts new file mode 100644 index 00000000..9be65311 --- /dev/null +++ b/apps/artifact-worker/src/index.ts @@ -0,0 +1,83 @@ +import { ArtifactWorkerEnvSchema } from "@cheatcode/env"; +import { + createPerformanceMetricMiddleware, + createWorkerRuntime, + requestId, + routeWorkerError, + toAPIError, +} from "@cheatcode/observability"; +import { normalizeTelemetryPath } from "@cheatcode/types"; +import { type Context, Hono } from "hono"; +import { routePath } from "hono/route"; +import type { ArtifactEnv } from "./artifact-env"; +import { registerOutputRoutes } from "./output-routes"; + +const artifactApp = new Hono<{ Bindings: ArtifactEnv }>(); + +artifactApp.onError((error, context) => { + throw routeWorkerError(error, registeredRouteName(context)); +}); + +artifactApp.use( + "*", + createPerformanceMetricMiddleware>({ + errorStatus: (error) => toAPIError(error).status, + metricFields: (c) => { + const colo = requestColo(c.req.raw); + const placement = c.req.header("cf-placement"); + return { + ...(colo ? { colo } : {}), + envTag: c.env.CHEATCODE_ENVIRONMENT, + ...(placement ? { placement } : {}), + ...(c.env.CF_VERSION_METADATA?.id || c.env.CHEATCODE_RELEASE_SHA + ? { versionTag: c.env.CF_VERSION_METADATA?.id ?? c.env.CHEATCODE_RELEASE_SHA } + : {}), + }; + }, + routeName: registeredRouteName, + workerName: "artifact", + }), +); + +artifactApp.get("/health", (c) => + c.json({ + ok: true, + releaseSha: c.env.CHEATCODE_RELEASE_SHA ?? "development", + versionId: c.env.CF_VERSION_METADATA?.id ?? null, + worker: "artifact", + }), +); + +registerOutputRoutes(artifactApp); + +export default createWorkerRuntime({ + errorCategory: "artifact", + errorLogFields: ({ route }) => ({ route, workerName: "artifact" }), + errorLogName: "artifact_request_failed", + fetch: async (request, env, ctx) => { + ArtifactWorkerEnvSchema.parse(env); + return artifactApp.fetch(request, env, ctx); + }, + formatError: ({ error, requestId: id }) => toAPIError(error).toResponse(id), + requestId: (request) => request.headers.get("X-Request-Id") ?? requestId(), + routeName, + workerName: "artifact", +}); + +function routeName(request: Request): string { + const url = new URL(request.url); + return `${request.method} ${normalizeTelemetryPath(url.pathname)}`; +} + +function registeredRouteName(c: Context<{ Bindings: ArtifactEnv }>): string { + try { + return `${c.req.method} ${routePath(c, -1)}`; + } catch { + return routeName(c.req.raw); + } +} + +function requestColo(request: Request): string | undefined { + const colo = request.cf?.colo; + return typeof colo === "string" ? colo : undefined; +} diff --git a/apps/artifact-worker/src/output-routes.ts b/apps/artifact-worker/src/output-routes.ts new file mode 100644 index 00000000..42fb8591 --- /dev/null +++ b/apps/artifact-worker/src/output-routes.ts @@ -0,0 +1,234 @@ +import { + createOutputDownloadCapability, + OutputDownloadQuerySchema, + verifySignedOutputDownload, +} from "@cheatcode/auth"; +import { findGeneratedOutput, withUserDb } from "@cheatcode/db"; +import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env"; +import { APIError } from "@cheatcode/observability"; +import { OutputIdSchema, toUserId, type UserId } from "@cheatcode/types"; +import { AGENT_FORWARD_ROUTES } from "@cheatcode/types/internal"; +import type { Context, Hono } from "hono"; +import { z } from "zod"; +import type { ArtifactEnv } from "./artifact-env"; + +type ArtifactContext = Context<{ Bindings: ArtifactEnv }>; + +export function registerOutputRoutes(app: Hono<{ Bindings: ArtifactEnv }>): void { + const routes = AGENT_FORWARD_ROUTES.core; + app.on( + routes.mintOutputDownloadUrl.method, + routes.mintOutputDownloadUrl.path, + mintOutputDownloadUrl, + ); + app.on(routes.downloadOutput.method, routes.downloadOutput.path, downloadOutput); +} + +async function mintOutputDownloadUrl(c: ArtifactContext): Promise { + const outputId = parseOutputId(c.req.param("outputId")); + const userId = toUserId(readGatewayUserId(c.req.raw.headers)); + const output = await findDownloadableOutput(c.env, outputId, userId); + if (!(await c.env.R2_OUTPUTS.head(output.r2Key))) { + throw outputNotFound("Output object not found"); + } + const capability = await createOutputDownloadCapability({ + baseUrl: c.env.OUTPUT_DOWNLOAD_BASE_URL, + outputId, + secret: await resolveOutputSigningSecret(c.env.OUTPUT_DOWNLOAD_SIGNING_SECRET), + userId, + }); + const response = c.json(capability); + setPrivateDownloadHeaders(response.headers); + return response; +} + +async function downloadOutput(c: ArtifactContext): Promise { + const outputId = parseOutputId(c.req.param("outputId")); + const query = parseOutputDownloadQuery(c); + if (!(await isAuthorizedDownload(c.env, outputId, query))) { + throw new APIError(403, "permission_access_denied", "Invalid or expired output download URL", { + retriable: false, + }); + } + const output = await findDownloadableOutput(c.env, outputId, query.userId); + let object: R2Object | R2ObjectBody | null; + try { + object = await c.env.R2_OUTPUTS.get(output.r2Key, { + onlyIf: c.req.raw.headers, + range: c.req.raw.headers, + }); + } catch (error) { + if (!isInvalidRangeError(error)) { + throw error; + } + const metadata = await c.env.R2_OUTPUTS.head(output.r2Key); + if (!metadata) { + throw outputNotFound("Output object not found"); + } + return invalidRangeResponse(metadata, output.filename, output.mimeType); + } + if (!object) { + throw outputNotFound("Output object not found"); + } + const headers = outputHeaders(object, output.filename, output.mimeType); + if (!("body" in object)) { + headers.delete("Content-Length"); + headers.delete("Content-Range"); + return new Response(null, { + headers, + status: conditionalReadStatus(c.req.raw.headers), + }); + } + return new Response(object.body, { + headers, + status: object.range ? 206 : 200, + }); +} + +function invalidRangeResponse(object: R2Object, filename: string, mimeType: string): Response { + const headers = outputHeaders(object, filename, mimeType); + headers.set("Content-Length", "0"); + headers.set("Content-Range", `bytes */${object.size}`); + return new Response(null, { headers, status: 416 }); +} + +function isInvalidRangeError(error: unknown): boolean { + return error instanceof Error && /\(10039\)$/u.test(error.message); +} + +function conditionalReadStatus(headers: Headers): 304 | 412 { + return headers.has("If-None-Match") || headers.has("If-Modified-Since") ? 304 : 412; +} + +async function isAuthorizedDownload( + env: ArtifactEnv, + outputId: string, + query: z.infer, +): Promise { + return verifySignedOutputDownload({ + expires: query.expires, + outputId, + secret: await resolveOutputSigningSecret(env.OUTPUT_DOWNLOAD_SIGNING_SECRET), + signature: query.sig, + userId: query.userId, + }); +} + +function outputHeaders(object: R2Object, filename: string, mimeType: string): Headers { + const headers = new Headers(); + object.writeHttpMetadata(headers); + setPrivateDownloadHeaders(headers); + headers.set("Accept-Ranges", "bytes"); + headers.set("Content-Disposition", downloadContentDisposition(filename)); + headers.set("Content-Type", mimeType); + headers.set("ETag", object.httpEtag); + headers.set("X-Content-Type-Options", "nosniff"); + const range = object.range ? normalizedRange(object.range, object.size) : undefined; + if (range) { + headers.set("Content-Length", String(range.length)); + headers.set( + "Content-Range", + `bytes ${range.offset}-${range.offset + range.length - 1}/${object.size}`, + ); + } else { + headers.set("Content-Length", String(object.size)); + } + return headers; +} + +function normalizedRange(range: R2Range, size: number): { length: number; offset: number } { + if ("suffix" in range) { + const length = Math.min(range.suffix, size); + return { length, offset: size - length }; + } + const offset = range.offset ?? 0; + return { length: Math.min(range.length ?? size - offset, size - offset), offset }; +} + +function setPrivateDownloadHeaders(headers: Headers): void { + headers.set("Cache-Control", "private, max-age=0, no-store"); + headers.set("Cross-Origin-Resource-Policy", "cross-origin"); + headers.set("Referrer-Policy", "no-referrer"); +} + +function parseOutputId(value: string | undefined): string { + const parsed = OutputIdSchema.safeParse(value); + if (!parsed.success) { + throw new APIError(400, "request_path_param_invalid", "Invalid output id", { + details: { issues: parsed.error.issues.map((issue) => issue.message) }, + retriable: false, + }); + } + return parsed.data; +} + +function parseOutputDownloadQuery(c: ArtifactContext): z.infer { + const parsed = OutputDownloadQuerySchema.safeParse({ + expires: c.req.query("expires"), + sig: c.req.query("sig"), + userId: c.req.query("userId"), + }); + if (!parsed.success) { + throw new APIError(400, "request_query_param_invalid", "Invalid output download signature", { + details: { issues: parsed.error.issues.map((issue) => issue.message) }, + retriable: false, + }); + } + return parsed.data; +} + +async function findDownloadableOutput(env: ArtifactEnv, outputId: string, userId: UserId) { + return withUserDb(env, userId, async ({ transaction }) => { + const output = await transaction((tx) => findGeneratedOutput(tx, { outputId, userId })); + if (!output) { + throw outputNotFound("Output not found"); + } + return output; + }); +} + +function readGatewayUserId(headers: Headers): string { + const parsed = z.string().uuid().safeParse(headers.get("X-Cheatcode-User-Id")); + if (!parsed.success) { + throw new APIError(401, "auth_token_missing", "Missing gateway user header", { + hint: "Call artifact-worker through gateway-worker service binding.", + retriable: false, + }); + } + return parsed.data; +} + +async function resolveOutputSigningSecret(secret: WorkerSecret): Promise { + try { + return await resolveWorkerSecret(secret); + } catch { + throw new APIError( + 503, + "service_maintenance_unavailable", + "Output signing secret is unavailable", + { retriable: true }, + ); + } +} + +function outputNotFound(message: string): APIError { + return new APIError(404, "resource_output_not_found", message, { retriable: false }); +} + +function downloadContentDisposition(filename: string): string { + const sanitized = Array.from(filename, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || + codePoint === 127 || + character === "/" || + character === "\\" || + character === '"' + ? "_" + : character; + }) + .slice(0, 200) + .join(""); + const safeName = sanitized || "cheatcode-output"; + const asciiFallback = safeName.replaceAll(/[^\x20-\x7e]/gu, "_"); + return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(safeName)}`; +} diff --git a/apps/artifact-worker/tsconfig.json b/apps/artifact-worker/tsconfig.json new file mode 100644 index 00000000..7a13c54a --- /dev/null +++ b/apps/artifact-worker/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../packages/tsconfig/worker.json", + "include": ["src/**/*"] +} diff --git a/apps/artifact-worker/wrangler.jsonc b/apps/artifact-worker/wrangler.jsonc new file mode 100644 index 00000000..ace1667a --- /dev/null +++ b/apps/artifact-worker/wrangler.jsonc @@ -0,0 +1,73 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cheatcode-artifacts", + "workers_dev": false, + "preview_urls": false, + "main": "src/index.ts", + "compatibility_date": "2026-07-15", + "compatibility_flags": ["nodejs_compat"], + "placement": { + "mode": "smart" + }, + "version_metadata": { + "binding": "CF_VERSION_METADATA" + }, + "vars": { + "CHEATCODE_ENVIRONMENT": "production" + }, + "secrets_store_secrets": [ + { + "binding": "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", + "store_id": "ba25994718db4707ab99a498e22eb5a6", + "secret_name": "database-context-signing-secret-agent" + }, + { + "binding": "OUTPUT_DOWNLOAD_SIGNING_SECRET", + "store_id": "ba25994718db4707ab99a498e22eb5a6", + "secret_name": "output-download-signing-secret" + } + ], + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "6fda3a31b1dc46e8ac736cbad79d5bc8" + } + ], + "r2_buckets": [ + { + "binding": "R2_OUTPUTS", + "bucket_name": "cheatcode-outputs" + } + ], + "analytics_engine_datasets": [ + { + "binding": "USER_EVENTS", + "dataset": "cc_user_events" + }, + { + "binding": "AGENT_METRICS", + "dataset": "cc_agent_metrics" + }, + { + "binding": "ERROR_EVENTS", + "dataset": "cc_error_events" + }, + { + "binding": "PERFORMANCE_METRICS", + "dataset": "cc_performance_metrics" + } + ], + "observability": { + "enabled": true, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "persist": true + }, + "traces": { + "enabled": true, + "head_sampling_rate": 0.1, + "persist": true + } + } +} diff --git a/apps/gateway-worker/README.md b/apps/gateway-worker/README.md index cf2b6272..6c2eeba9 100644 --- a/apps/gateway-worker/README.md +++ b/apps/gateway-worker/README.md @@ -114,8 +114,9 @@ that exact contract before using existing storage. `GET /health/live` is a cheap gateway-only liveness response. `GET /health/release` exposes the gateway release SHA plus the release SHAs -reported by its agent and webhook service bindings, and returns 503 while they -converge. Deployments publish the gateway last so public traffic observes only a +for Agent, Artifact, and Webhooks Workers and remains unhealthy until all four +versions converge. +Deployments publish the gateway last so public traffic observes only a backend set built from the same reviewed revision. SQLite schema validation remains synchronous. @@ -147,12 +148,15 @@ pnpm --filter @cheatcode/gateway-worker typecheck - `CHEATCODE_RELEASE_SHA` (required for production deployments) - `CF_VERSION_METADATA` - `AGENT` +- `ARTIFACTS` (private Service Binding for signed R2 output minting and delivery) - `WEBHOOKS` - `RESOURCE_DELETION` (named `ResourceDeletionEntrypoint` Service Binding; granted only to gateway with authenticated caller/capability properties) - `PREVIEW_PROXY` (generated local-only Service Binding; production preview traffic reaches the preview Worker through its wildcard route) - `RATE_LIMITER` +- `RATE_LIMIT_PUBLIC_READ`, `RATE_LIMIT_READ_CHEAP` (local, eventually + consistent native bindings for non-critical cheap reads) - `QUOTA_TRACKER` (named `GatewayQuotaEntrypoint` Service Binding to agent-worker; grants only `peek`, `history`, and `setLimit`) - `IDEMPOTENCY` diff --git a/apps/gateway-worker/src/agent-forwarding.ts b/apps/gateway-worker/src/agent-forwarding.ts index 8aea52ea..6336715f 100644 --- a/apps/gateway-worker/src/agent-forwarding.ts +++ b/apps/gateway-worker/src/agent-forwarding.ts @@ -1,6 +1,6 @@ import { type AgentForwardRoute, agentForwardRouteKey } from "@cheatcode/types/internal"; import { authenticate } from "./authenticate"; -import type { GatewayContext } from "./gateway-env"; +import { type GatewayContext, requestPerformance } from "./gateway-env"; import { rateLimitForwarded, rateLimitPublicForwarded, withRateLimitHeaders } from "./rate-limit"; const PUBLIC_CREDENTIAL_HEADERS = [ @@ -59,14 +59,34 @@ export async function forwardAgentRequest( const headers = await rateLimitForwarded(c, userId, routeKey, route.rateLimitCost); validate?.(c); const forwarded = agentServiceRequest(c.req.raw, userId); - return withRateLimitHeaders(await c.env.AGENT.fetch(forwarded), headers); + const response = await requestPerformance(c).measure("serviceBinding", () => + c.env.AGENT.fetch(forwarded), + ); + return withRateLimitHeaders(response, headers); } -export async function forwardPublicAgentRequest( +export async function forwardArtifactRequest( + c: GatewayContext, + route: AgentForwardRoute, +): Promise { + const userId = await authenticate(c); + const routeKey = agentForwardRouteKey(route); + const headers = await rateLimitForwarded(c, userId, routeKey, route.rateLimitCost); + const request = agentServiceRequest(c.req.raw, userId); + const response = await requestPerformance(c).measure("serviceBinding", () => + c.env.ARTIFACTS.fetch(request), + ); + return withRateLimitHeaders(response, headers); +} + +export async function forwardPublicArtifactRequest( c: GatewayContext, route: AgentForwardRoute, ): Promise { const routeKey = agentForwardRouteKey(route); const headers = await rateLimitPublicForwarded(c, routeKey, "publicRead", route.rateLimitCost); - return withRateLimitHeaders(await c.env.AGENT.fetch(agentServiceRequest(c.req.raw)), headers); + const response = await requestPerformance(c).measure("serviceBinding", () => + c.env.ARTIFACTS.fetch(agentServiceRequest(c.req.raw)), + ); + return withRateLimitHeaders(response, headers); } diff --git a/apps/gateway-worker/src/agent-http-routes.ts b/apps/gateway-worker/src/agent-http-routes.ts index da34ac29..46b2c492 100644 --- a/apps/gateway-worker/src/agent-http-routes.ts +++ b/apps/gateway-worker/src/agent-http-routes.ts @@ -1,3 +1,4 @@ +import { createLogger } from "@cheatcode/observability"; import { AGENT_FORWARD_ROUTES } from "@cheatcode/types/internal"; import { agentServiceHeaders, @@ -6,7 +7,7 @@ import { } from "./agent-forwarding"; import { validateSandboxConsoleQuery } from "./agent-proxy-routes"; import { authenticate, requireVerifiedClerkEmail } from "./authenticate"; -import type { GatewayApp, GatewayContext } from "./gateway-env"; +import { type GatewayApp, type GatewayContext, requestPerformance } from "./gateway-env"; import { completeIdempotentRunRequest, prepareIdempotentRunRequest } from "./idempotency"; import { rateLimit, withRateLimitHeaders } from "./rate-limit"; @@ -29,8 +30,10 @@ export function registerAgentHttpRoutes(app: GatewayApp): void { async function createRunRoute(c: GatewayContext): Promise { const userId = await authenticate(c); const rateLimitHeaders = await rateLimit(c, userId); - await requireVerifiedClerkEmail(c.req.raw, c.env); - const prepared = await prepareIdempotentRunRequest(c.env, c.req.raw, userId); + await requireVerifiedClerkEmail(c); + const prepared = await requestPerformance(c).measure("durableObject", () => + prepareIdempotentRunRequest(c.env, c.req.raw, userId), + ); if (prepared.replay) { return withRateLimitHeaders(prepared.replay, rateLimitHeaders); } @@ -43,7 +46,7 @@ async function createRunRoute(c: GatewayContext): Promise { request.headers.set("X-Cheatcode-Idempotency-Key-Hash", prepared.keyHash); request.headers.set("X-Cheatcode-Request-Body-Hash", prepared.bodyHash); request.headers.set("X-Cheatcode-User-Id", userId); - return c.env.AGENT.fetch(request); + return requestPerformance(c).measure("serviceBinding", () => c.env.AGENT.fetch(request)); }; let response: Response; let hasRetried = false; @@ -61,6 +64,16 @@ async function createRunRoute(c: GatewayContext): Promise { // its response was delivered. The same persisted request identity makes retry safe. response = await forward(); } - await completeIdempotentRunRequest(c.env, userId, prepared.key, prepared.claimId, response); + if (response.status === 202) { + c.executionCtx.waitUntil( + completeIdempotentRunRequest(c.env, userId, prepared.key, prepared.claimId, response).catch( + (error: unknown) => { + createLogger({ userId }).warn("run_idempotency_completion_deferred", { error }); + }, + ), + ); + } else { + await completeIdempotentRunRequest(c.env, userId, prepared.key, prepared.claimId, response); + } return withRateLimitHeaders(response, rateLimitHeaders); } diff --git a/apps/gateway-worker/src/auth-context.ts b/apps/gateway-worker/src/auth-context.ts new file mode 100644 index 00000000..6fe26ce4 --- /dev/null +++ b/apps/gateway-worker/src/auth-context.ts @@ -0,0 +1,12 @@ +import type { UserId } from "@cheatcode/types"; + +export interface GatewayPrimaryEmailStatus { + email: string | null; + verified: boolean; +} + +export interface GatewayPrincipal { + clerkUserId: string; + primaryEmailStatus(): Promise; + userId: UserId; +} diff --git a/apps/gateway-worker/src/authenticate.ts b/apps/gateway-worker/src/authenticate.ts index c0affede..bda5cfd1 100644 --- a/apps/gateway-worker/src/authenticate.ts +++ b/apps/gateway-worker/src/authenticate.ts @@ -17,7 +17,8 @@ import { } from "@cheatcode/env"; import { APIError } from "@cheatcode/observability"; import type { UserId } from "@cheatcode/types"; -import { type GatewayContext, requestDatabase } from "./gateway-env"; +import type { GatewayPrimaryEmailStatus, GatewayPrincipal } from "./auth-context"; +import { type GatewayContext, requestDatabase, requestPerformance } from "./gateway-env"; /** * Narrow env surface the auth helpers depend on. `GatewayEnv` structurally @@ -32,9 +33,29 @@ export interface AuthEnv { } export async function authenticate(c: GatewayContext): Promise { - const { secretKey, verificationOptions } = await clerkVerification(c.env); - const session = await verifyClerkBearerToken(c.req.raw, verificationOptions); - return resolveOrSyncClerkUser(requestDatabase(c).db, session.clerkUserId, secretKey); + return (await c.get("principal")()).userId; +} + +export async function resolveGatewayPrincipal(c: GatewayContext): Promise { + const recorder = requestPerformance(c); + const { secretKey, verificationOptions } = await recorder.measure("authSecret", () => + clerkVerification(c.env), + ); + const session = await recorder.measure("authVerification", () => + verifyClerkBearerToken(c.req.raw, verificationOptions), + ); + const userId = await recorder.measure("userLookup", () => + resolveOrSyncClerkUser(requestDatabase(c).db, session.clerkUserId, secretKey), + ); + let emailStatus: Promise | undefined; + return { + clerkUserId: session.clerkUserId, + primaryEmailStatus() { + emailStatus ??= fetchClerkEmailStatus(session.clerkUserId, secretKey); + return emailStatus; + }, + userId, + }; } async function clerkVerification(env: AuthEnv) { @@ -124,13 +145,8 @@ async function fetchCanonicalClerkSnapshot( } } -export async function requireVerifiedClerkEmail(request: Request, env: AuthEnv): Promise { - const secretKey = await readRequiredClerkSecret(env); - const session = await verifyClerkBearerToken(request, { - authorizedParties: clerkAuthorizedParties(env), - secretKey, - }); - const emailStatus = await fetchClerkEmailStatus(session.clerkUserId, secretKey); +export async function requireVerifiedClerkEmail(c: GatewayContext): Promise { + const emailStatus = await (await c.get("principal")()).primaryEmailStatus(); if (emailStatus.verified) { return; } @@ -238,24 +254,6 @@ export async function readOptionalClerkSecret( return value ? assertClerkSecretKeyFamily(value, env.CHEATCODE_ENVIRONMENT) : undefined; } -async function readRequiredClerkSecret( - env: Pick, -): Promise { - const value = await readOptionalClerkSecret(env); - if (!value) { - throw new APIError( - 503, - "service_maintenance_unavailable", - "CLERK_SECRET_KEY is not configured", - { - hint: "Set CLERK_SECRET_KEY in the gateway Worker environment.", - retriable: false, - }, - ); - } - return value; -} - function assertClerkSecretKeyFamily( value: string, environment: AuthEnv["CHEATCODE_ENVIRONMENT"], diff --git a/apps/gateway-worker/src/bootstrap-http-routes.ts b/apps/gateway-worker/src/bootstrap-http-routes.ts new file mode 100644 index 00000000..f73b9279 --- /dev/null +++ b/apps/gateway-worker/src/bootstrap-http-routes.ts @@ -0,0 +1,12 @@ +import { authenticate } from "./authenticate"; +import { navigationBootstrapRoute } from "./bootstrap-routes"; +import { type GatewayApp, requestDatabase } from "./gateway-env"; +import { rateLimit } from "./rate-limit"; + +export function registerBootstrapHttpRoutes(app: GatewayApp): void { + app.get("/v1/bootstrap", async (c) => { + const userId = await authenticate(c); + await rateLimit(c, userId); + return navigationBootstrapRoute(requestDatabase(c), c.req.raw, userId); + }); +} diff --git a/apps/gateway-worker/src/bootstrap-routes.ts b/apps/gateway-worker/src/bootstrap-routes.ts new file mode 100644 index 00000000..8e45a0a7 --- /dev/null +++ b/apps/gateway-worker/src/bootstrap-routes.ts @@ -0,0 +1,79 @@ +import { + type DatabaseHandle, + loadNavigationBootstrap, + type NavigationProjectRecord, + type WorkspaceThreadSearchRecord, + withUserDb, +} from "@cheatcode/db"; +import { APIError } from "@cheatcode/observability"; +import { toThreadId, type UserId } from "@cheatcode/types"; +import { + NavigationBootstrapQuerySchema, + NavigationBootstrapResponseSchema, +} from "@cheatcode/types/api"; + +/** Returns the complete bounded navigation snapshot after one auth and rate-limit decision. */ +export async function navigationBootstrapRoute( + database: DatabaseHandle, + request: Request, + userId: UserId, +): Promise { + const query = parseNavigationBootstrapQuery(request); + return withUserDb(database, userId, async ({ transaction }) => { + const snapshot = await transaction((db) => + loadNavigationBootstrap(db, { + ...(query.activeThreadId ? { activeThreadId: toThreadId(query.activeThreadId) } : {}), + userId, + }), + ); + return Response.json( + NavigationBootstrapResponseSchema.parse({ + activeProjectId: snapshot.activeProjectId, + projects: snapshot.projects.map(navigationProjectResponse), + recentThreads: snapshot.recentThreads.map(recentThreadResponse), + }), + ); + }); +} + +function parseNavigationBootstrapQuery(request: Request) { + const url = new URL(request.url); + const parsed = NavigationBootstrapQuerySchema.safeParse({ + activeThreadId: url.searchParams.get("activeThreadId") ?? undefined, + }); + if (!parsed.success) { + throw new APIError(400, "request_query_param_invalid", "Invalid bootstrap query", { + details: { issues: parsed.error.issues.map((issue) => issue.message) }, + retriable: false, + }); + } + return parsed.data; +} + +function navigationProjectResponse(project: NavigationProjectRecord) { + return { + archiveAfter: project.archiveAfter?.toISOString() ?? null, + createdAt: project.createdAt.toISOString(), + defaultModel: project.defaultModel, + id: project.id, + importRepoUrl: project.importRepoUrl, + latestThreadId: project.latestThreadId, + mode: project.mode, + name: project.name, + overQuota: project.overQuota, + readOnly: project.readOnly, + updatedAt: project.updatedAt.toISOString(), + }; +} + +function recentThreadResponse(thread: WorkspaceThreadSearchRecord) { + return { + activeRunId: thread.activeRunId, + id: thread.id, + projectId: thread.projectId, + projectName: thread.projectName, + title: thread.title, + type: thread.type, + updatedAt: thread.updatedAt.toISOString(), + }; +} diff --git a/apps/gateway-worker/src/core-http-routes.ts b/apps/gateway-worker/src/core-http-routes.ts index e1bf0627..6b7e8c03 100644 --- a/apps/gateway-worker/src/core-http-routes.ts +++ b/apps/gateway-worker/src/core-http-routes.ts @@ -1,7 +1,11 @@ import { APIError } from "@cheatcode/observability"; import type { UserId } from "@cheatcode/types"; import { AGENT_FORWARD_ROUTES } from "@cheatcode/types/internal"; -import { forwardAgentRequest, forwardPublicAgentRequest } from "./agent-forwarding"; +import { + forwardAgentRequest, + forwardArtifactRequest, + forwardPublicArtifactRequest, +} from "./agent-forwarding"; import { authenticate } from "./authenticate"; import { type GatewayApp, type GatewayContext, requestDatabase } from "./gateway-env"; import { rateLimit, rateLimitPublic, withRateLimitHeaders } from "./rate-limit"; @@ -28,14 +32,20 @@ function registerHealthRoute(app: GatewayApp): void { app.get("/health/release", async (c) => { const headers = await rateLimitPublic(c, "publicRead"); const releaseSha = c.env.CHEATCODE_RELEASE_SHA ?? "development"; - const [{ health: agent }, { health: webhooks }] = await Promise.all([ + const [{ health: agent }, { health: artifact }, { health: webhooks }] = await Promise.all([ readDownstreamReleaseHealth(c.env, "agent"), + readDownstreamReleaseHealth(c.env, "artifact"), readDownstreamReleaseHealth(c.env, "webhooks"), ]); - if (agent.releaseSha !== releaseSha || webhooks.releaseSha !== releaseSha) { + if ( + agent.releaseSha !== releaseSha || + artifact.releaseSha !== releaseSha || + webhooks.releaseSha !== releaseSha + ) { throw new APIError(503, "service_maintenance_unavailable", "Release is still converging", { details: { agentReleaseSha: agent.releaseSha, + artifactReleaseSha: artifact.releaseSha, gatewayReleaseSha: releaseSha, webhooksReleaseSha: webhooks.releaseSha, }, @@ -45,6 +55,7 @@ function registerHealthRoute(app: GatewayApp): void { return withRateLimitHeaders( c.json({ agent, + artifact, ok: true, releaseSha, versionId: c.env.CF_VERSION_METADATA?.id ?? null, @@ -74,10 +85,10 @@ function registerTelemetryRoutes(app: GatewayApp): void { function registerOutputRoute(app: GatewayApp): void { const { downloadOutput, mintOutputDownloadUrl } = AGENT_FORWARD_ROUTES.core; app.on(mintOutputDownloadUrl.method, mintOutputDownloadUrl.path, (c) => - forwardAgentRequest(c, mintOutputDownloadUrl), + forwardArtifactRequest(c, mintOutputDownloadUrl), ); app.on(downloadOutput.method, downloadOutput.path, (c) => - forwardPublicAgentRequest(c, downloadOutput), + forwardPublicArtifactRequest(c, downloadOutput), ); } diff --git a/apps/gateway-worker/src/gateway-env.ts b/apps/gateway-worker/src/gateway-env.ts index 9473d988..ca25448e 100644 --- a/apps/gateway-worker/src/gateway-env.ts +++ b/apps/gateway-worker/src/gateway-env.ts @@ -1,15 +1,17 @@ import type { DatabaseHandle } from "@cheatcode/db"; import type { CloudflareVersionMetadata, WorkerSecret } from "@cheatcode/env"; -import type { AnalyticsBindings } from "@cheatcode/observability"; +import type { AnalyticsBindings, PerformanceRecorder } from "@cheatcode/observability"; import type { ResourceDeletionServiceBinding } from "@cheatcode/types/internal"; import type { GatewayQuotaServiceBinding } from "@cheatcode/types/quota"; import type { Context, Hono } from "hono"; +import type { GatewayPrincipal } from "./auth-context"; import type { IdempotencyStore } from "./durable-objects/idempotency"; import type { RateLimiter } from "./durable-objects/rate-limiter"; import type { IdempotencyBindings } from "./idempotency"; export interface GatewayEnv extends AnalyticsBindings, IdempotencyBindings { AGENT: Fetcher; + ARTIFACTS: Fetcher; CF_VERSION_METADATA?: CloudflareVersionMetadata; CHEATCODE_ENVIRONMENT: "development" | "production"; CHEATCODE_RELEASE_SHA?: string; @@ -27,6 +29,8 @@ export interface GatewayEnv extends AnalyticsBindings, IdempotencyBindings { POLAR_SERVER?: "production" | "sandbox"; PREVIEW_PROXY?: Fetcher; QUOTA_TRACKER: GatewayQuotaServiceBinding; + RATE_LIMIT_PUBLIC_READ: RateLimit; + RATE_LIMIT_READ_CHEAP: RateLimit; RATE_LIMITER: DurableObjectNamespace; RESOURCE_DELETION: ResourceDeletionServiceBinding; WEBHOOKS: Fetcher; @@ -34,6 +38,8 @@ export interface GatewayEnv extends AnalyticsBindings, IdempotencyBindings { interface GatewayVariables { database: () => DatabaseHandle; + performance: PerformanceRecorder; + principal: () => Promise; } export type GatewayHonoEnv = { Bindings: GatewayEnv; Variables: GatewayVariables }; @@ -43,3 +49,7 @@ export type GatewayContext = Context; export function requestDatabase(c: GatewayContext): DatabaseHandle { return c.get("database")(); } + +export function requestPerformance(c: GatewayContext): PerformanceRecorder { + return c.get("performance"); +} diff --git a/apps/gateway-worker/src/index.ts b/apps/gateway-worker/src/index.ts index 0cc77d1d..e68a04e8 100644 --- a/apps/gateway-worker/src/index.ts +++ b/apps/gateway-worker/src/index.ts @@ -3,9 +3,11 @@ import { GatewayWorkerEnvSchema, PRODUCTION_APP_ORIGIN } from "@cheatcode/env"; import { APIError, createPerformanceMetricMiddleware, + createPerformanceRecorder, createWorkerRuntime, reportWorkerError, requestId, + safeServerTiming, } from "@cheatcode/observability"; import { normalizeTelemetryPath } from "@cheatcode/types"; import { Hono } from "hono"; @@ -14,13 +16,20 @@ import { routePath } from "hono/route"; import { secureHeaders } from "hono/secure-headers"; import { registerActivityHttpRoutes } from "./activity-http-routes"; import { registerAgentHttpRoutes } from "./agent-http-routes"; +import { resolveGatewayPrincipal } from "./authenticate"; import { registerBillingHttpRoutes } from "./billing-http-routes"; +import { registerBootstrapHttpRoutes } from "./bootstrap-http-routes"; import { registerCoreHttpRoutes } from "./core-http-routes"; import { resolveCorsOrigin } from "./cors"; import { IdempotencyStore } from "./durable-objects/idempotency"; import { RateLimiter } from "./durable-objects/rate-limiter"; import { formatGatewayRouteError } from "./error-handling"; -import type { GatewayContext, GatewayEnv, GatewayHonoEnv } from "./gateway-env"; +import { + type GatewayContext, + type GatewayEnv, + type GatewayHonoEnv, + requestPerformance, +} from "./gateway-env"; import { registerGreetingHttpRoutes } from "./greeting-http-routes"; import { registerIntegrationHttpRoutes } from "./integration-http-routes"; import { resolveLocalPreviewRoute } from "./local-preview-routing"; @@ -39,6 +48,7 @@ const CORS_EXPOSED_HEADERS = [ "RateLimit-Remaining", "RateLimit-Reset", "Retry-After", + "Server-Timing", "X-Request-Id", ]; const GATEWAY_SECURITY_HEADERS = { @@ -72,6 +82,17 @@ const GATEWAY_SECURITY_HEADERS = { const gatewayApp = new Hono(); +gatewayApp.use("*", async (c, next) => { + const recorder = createPerformanceRecorder(); + let principal: ReturnType | undefined; + c.set("performance", recorder); + c.set("principal", () => { + principal ??= resolveGatewayPrincipal(c); + return principal; + }); + await next(); +}); + // 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 @@ -98,7 +119,18 @@ gatewayApp.use("*", async (c, next) => { gatewayApp.use("/v1/*", async (c, next) => { let handle: DatabaseHandle | undefined; c.set("database", () => { - handle ??= createDatabaseHandle(c.env); + handle ??= createDatabaseHandle(c.env, { + onTiming: (phase, durationMs) => { + const recorder = requestPerformance(c); + const performancePhase = + phase === "connection" + ? "databaseConnection" + : phase === "context" + ? "databaseContext" + : "databaseQuery"; + recorder.add(performancePhase, durationMs); + }, + }); return handle; }); try { @@ -121,9 +153,34 @@ gatewayApp.use( ), }), ); +gatewayApp.use("/v1/*", async (c, next) => { + await next(); + if (c.req.method !== "OPTIONS" && !c.res.headers.has("Cache-Control")) { + c.res.headers.set("Cache-Control", "private, no-store"); + } +}); gatewayApp.use( "*", createPerformanceMetricMiddleware({ + decorateResponse: (c, recorder) => { + if (recorder) c.header("Server-Timing", safeServerTiming(recorder)); + const timingOrigin = resolveCorsOrigin(c.req.header("Origin"), c.env.CHEATCODE_ENVIRONMENT); + if (timingOrigin) c.header("Timing-Allow-Origin", timingOrigin); + }, + metricFields: (c) => { + const colo = requestColo(c.req.raw); + const placement = c.req.header("cf-placement"); + const reconnectState = requestReconnectState(c.req.raw); + const versionTag = c.env.CF_VERSION_METADATA?.id ?? c.env.CHEATCODE_RELEASE_SHA; + return { + ...(colo ? { colo } : {}), + envTag: c.env.CHEATCODE_ENVIRONMENT, + ...(placement ? { placement } : {}), + ...(reconnectState ? { reconnectState } : {}), + ...(versionTag ? { versionTag } : {}), + }; + }, + recorder: requestPerformance, routeName: routeNameForContext, workerName: "gateway", }), @@ -132,6 +189,7 @@ gatewayApp.use( registerCoreHttpRoutes(gatewayApp); registerActivityHttpRoutes(gatewayApp); registerProfileHttpRoutes(gatewayApp); +registerBootstrapHttpRoutes(gatewayApp); registerSearchHttpRoutes(gatewayApp); registerGreetingHttpRoutes(gatewayApp); registerProjectHttpRoutes(gatewayApp); @@ -145,6 +203,13 @@ function routeName(request: Request): string { return `${request.method} ${normalizeTelemetryPath(url.pathname)}`; } +function requestReconnectState(request: Request): "new" | "reconnect" | undefined { + const pathname = new URL(request.url).pathname; + if (request.method === "POST" && pathname.endsWith("/runs")) return "new"; + if (request.method === "GET" && pathname.endsWith("/runs/stream")) return "reconnect"; + return undefined; +} + function routeNameForContext(c: GatewayContext): string { try { return `${c.req.method} ${routePath(c, -1)}`; @@ -153,6 +218,11 @@ function routeNameForContext(c: GatewayContext): string { } } +function requestColo(request: Request): string | undefined { + const colo = request.cf?.colo; + return typeof colo === "string" ? colo : undefined; +} + const gatewayHandler = createWorkerRuntime({ errorCategory: "gateway", errorLogName: "gateway_request_failed", diff --git a/apps/gateway-worker/src/rate-limit.ts b/apps/gateway-worker/src/rate-limit.ts index 6098a6ea..acb66d09 100644 --- a/apps/gateway-worker/src/rate-limit.ts +++ b/apps/gateway-worker/src/rate-limit.ts @@ -1,4 +1,9 @@ -import { APIError, createLogger, safeErrorTelemetry } from "@cheatcode/observability"; +import { + APIError, + createLogger, + type PerformanceRecorder, + safeErrorTelemetry, +} from "@cheatcode/observability"; import type { UserId } from "@cheatcode/types"; import type { Context } from "hono"; import { routePath } from "hono/route"; @@ -28,6 +33,8 @@ export interface RateLimitHeaders { export interface RateLimitContext { env: { + RATE_LIMIT_PUBLIC_READ: RateLimit; + RATE_LIMIT_READ_CHEAP: RateLimit; RATE_LIMITER: DurableObjectNamespace; }; header(name: string, value: string): void; @@ -143,6 +150,24 @@ async function consumeRateLimit( route: string, policy: RateLimitPolicy, ): Promise { + const recorder = rateLimitRecorder(c); + if (recorder) { + return recorder.measure("rateLimit", () => + consumeRateLimitUnmeasured(c, subject, route, policy), + ); + } + return consumeRateLimitUnmeasured(c, subject, route, policy); +} + +async function consumeRateLimitUnmeasured( + c: RateLimitContext, + subject: RateLimitSubject, + route: string, + policy: RateLimitPolicy, +): Promise { + if (usesNativeRateLimit(policy)) { + return consumeNativeRateLimit(c, subject, route, policy); + } const id = c.env.RATE_LIMITER.idFromName(subject.durableObjectName); const stub = c.env.RATE_LIMITER.get(id); let result: RateLimitResult; @@ -159,6 +184,57 @@ async function consumeRateLimit( return headers; } +async function consumeNativeRateLimit( + c: RateLimitContext, + subject: RateLimitSubject, + route: string, + policy: RateLimitPolicy, +): Promise { + const binding = + policy.className === "public.read" ? c.env.RATE_LIMIT_PUBLIC_READ : c.env.RATE_LIMIT_READ_CHEAP; + let success: boolean; + try { + ({ success } = await binding.limit({ key: subject.key })); + } catch (error) { + return handleRateLimitFailure(route, policy, error); + } + if (!success) { + const retryAfterMs = 60_000; + throw new RateLimitExceededError( + { + limit: String(policy.limitPerMinute), + remaining: "0", + reset: rateLimitReset(retryAfterMs), + }, + policy, + retryAfterMs, + ); + } + return null; +} + +function usesNativeRateLimit(policy: RateLimitPolicy): boolean { + return ( + policy.cost === 1 && (policy.className === "public.read" || policy.className === "read.cheap") + ); +} + +function rateLimitRecorder(c: RateLimitContext): PerformanceRecorder | undefined { + try { + const get = Reflect.get(c, "get"); + if (typeof get !== "function") return undefined; + const recorder: unknown = Reflect.apply(get, c, ["performance"]); + return isPerformanceRecorder(recorder) ? recorder : undefined; + } catch { + return undefined; + } +} + +function isPerformanceRecorder(value: unknown): value is PerformanceRecorder { + if (typeof value !== "object" || value === null) return false; + return typeof Reflect.get(value, "measure") === "function"; +} + export function withRateLimitErrorHeaders(response: Response, error: unknown): Response { if (!(error instanceof RateLimitExceededError)) { return response; diff --git a/apps/gateway-worker/src/release-health.ts b/apps/gateway-worker/src/release-health.ts index c3f5a6fb..30798567 100644 --- a/apps/gateway-worker/src/release-health.ts +++ b/apps/gateway-worker/src/release-health.ts @@ -7,7 +7,7 @@ const DownstreamReleaseHealthSchema = z.strictObject({ ok: z.literal(true), releaseSha: z.string().min(1), versionId: z.string().min(1).nullable(), - worker: z.enum(["agent", "webhooks"]), + worker: z.enum(["agent", "artifact", "webhooks"]), }); export type DownstreamWorker = z.infer["worker"]; @@ -19,7 +19,7 @@ export interface DownstreamReleaseHealthResult { } export async function readDownstreamReleaseHealth( - env: Pick, + env: Pick, worker: DownstreamWorker, ): Promise { const response = await fetchHealth(env, worker); @@ -50,11 +50,12 @@ export async function readDownstreamReleaseHealth( } async function fetchHealth( - env: Pick, + env: Pick, worker: DownstreamWorker, ): Promise { try { - const binding = worker === "agent" ? env.AGENT : env.WEBHOOKS; + const binding = + worker === "agent" ? env.AGENT : worker === "artifact" ? env.ARTIFACTS : env.WEBHOOKS; return await binding.fetch( new Request(`https://${worker}.internal/health`, { signal: AbortSignal.timeout(3_000), @@ -80,5 +81,6 @@ function unhealthyService(worker: DownstreamWorker, status: number): APIError { } function serviceLabel(worker: DownstreamWorker): string { - return worker === "agent" ? "Agent" : "Webhooks"; + if (worker === "agent") return "Agent"; + return worker === "artifact" ? "Artifact" : "Webhooks"; } diff --git a/apps/gateway-worker/wrangler.jsonc b/apps/gateway-worker/wrangler.jsonc index e06c6c25..d2514959 100644 --- a/apps/gateway-worker/wrangler.jsonc +++ b/apps/gateway-worker/wrangler.jsonc @@ -25,6 +25,10 @@ "binding": "AGENT", "service": "cheatcode-agent" }, + { + "binding": "ARTIFACTS", + "service": "cheatcode-artifacts" + }, { "binding": "WEBHOOKS", "service": "cheatcode-webhooks" @@ -87,6 +91,24 @@ } ] }, + "ratelimits": [ + { + "name": "RATE_LIMIT_PUBLIC_READ", + "namespace_id": "10001", + "simple": { + "limit": 300, + "period": 60 + } + }, + { + "name": "RATE_LIMIT_READ_CHEAP", + "namespace_id": "10002", + "simple": { + "limit": 600, + "period": 60 + } + } + ], "kv_namespaces": [ { "binding": "ENTITLEMENTS_CACHE", diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index eacfbb83..6771f191 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { preconnect } from "react-dom"; import "./globals.css"; import "./effects.css"; import { ClientObservability } from "@/components/observability/client-observability"; @@ -33,6 +34,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: ReactNode }) { const clerkPublishableKey = env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + preconnect(env.NEXT_PUBLIC_GATEWAY_URL); return ( ; +interface RunTimingState { + reported: Set; + startedAt: number | null; +} export interface ChatPanelProps { activeRunId: null | string; @@ -108,18 +117,18 @@ function useChatRuntimeBase( const hasReceivedStreamDataRef = useRef(false); const hasSubmittedRef = useRef(false); const pendingSubmissionRef = useRef(null); + const runTimingRef = useRef({ reported: new Set(), startedAt: null }); const [runStartedAt, setRunStartedAt] = useState(() => input.activeRunId ? Date.now() : null, ); + const [provisionalModel, setProvisionalModel] = + useState(EMPTY_PROVISIONAL_MODEL); useEffect(() => { if (input.activeRunId !== null) { setRunStartedAt((current) => current ?? Date.now()); } }, [input.activeRunId]); - const transport = useMemo( - () => createChatTransport(input.threadId, getToken), - [getToken, input.threadId], - ); + const transport = useRunTimingTransport(input.threadId, getToken, runTimingRef); const cancelRunMutation = useCancelRun(input.threadId, getToken); const chat = useChatSession({ activeRunId: input.activeRunId, @@ -129,6 +138,8 @@ function useChatRuntimeBase( initialMessages: input.initialMessages ?? EMPTY_MESSAGES, pendingSubmissionRef, queryClient, + runTimingRef, + setProvisionalModel, setRunStartedAt, sandboxActions: store, viewApplier, @@ -145,13 +156,30 @@ function useChatRuntimeBase( hasReceivedStreamDataRef, hasSubmittedRef, pendingSubmissionRef, + provisionalModel, queryClient, + runTimingRef, router, runStartedAt, + setProvisionalModel, setRunStartedAt, }; } +function useRunTimingTransport( + threadId: string, + getToken: () => Promise, + runTimingRef: { current: RunTimingState }, +) { + return useMemo( + () => + createChatTransport(threadId, getToken, () => { + recordRunTiming(runTimingRef.current, "run_response_headers"); + }), + [getToken, runTimingRef, threadId], + ); +} + function usePanelSubmission( input: ChatPanelProps, runtime: ReturnType, @@ -172,6 +200,8 @@ function usePanelSubmission( selectedModel, sendMessage: runtime.chat.sendMessage, setDraft: runtime.store.setDraft, + startRunTiming: (startedAt: number) => + startRunTiming(runtime.runTimingRef.current, startedAt), setRunStartedAt: runtime.setRunStartedAt, status: runtime.chat.status, threadId: input.threadId, @@ -193,6 +223,7 @@ function usePanelSubmission( runtime.queryClient, runtime.router, runtime.store.setDraft, + runtime.runTimingRef, runtime.setRunStartedAt, selectedModel, ], @@ -241,9 +272,15 @@ function useChatPanelActions( submission: ReturnType, ) { const stopRun = useCallback(() => { + runtime.setProvisionalModel(EMPTY_PROVISIONAL_MODEL); runtime.chat.stop(); runtime.cancelRunMutation.mutate(input.activeRunId); - }, [input.activeRunId, runtime.cancelRunMutation, runtime.chat.stop]); + }, [ + input.activeRunId, + runtime.cancelRunMutation, + runtime.chat.stop, + runtime.setProvisionalModel, + ]); const setDraft = useCallback( (value: string) => runtime.store.setDraft(input.threadId, value), [input.threadId, runtime.store.setDraft], @@ -271,6 +308,7 @@ function chatPanelState(input: ChatPanelProps, runtime: ReturnType; + runTimingRef: { current: RunTimingState }; + setProvisionalModel: Dispatch>; setRunStartedAt: (value: null | number) => void; sandboxActions: SandboxStatusActions; viewApplier: ComputerViewApplier; @@ -363,9 +403,19 @@ function handleStreamData( ): void { input.hasReceivedStreamDataRef.current = true; input.pendingSubmissionRef.current = null; + if (isFirstStatusPart(part)) { + recordRunTiming(input.runTimingRef.current, "run_first_status"); + } if (part.type === "data-seq") { handleSequenceData(part.data, input.threadId); } + if (part.type === "data-model-provisional") { + const provisional = CHEATCODE_DATA_SCHEMAS["model-provisional"].safeParse(part.data); + if (provisional.success && provisional.data.phase === "delta") { + recordRunTiming(input.runTimingRef.current, "run_first_model_text"); + } + handleProvisionalModelData(part.data, input.setProvisionalModel); + } const viewCommand = computerViewEffect(part); if (viewCommand) { input.viewApplier.apply(viewCommand); @@ -385,6 +435,23 @@ function handleSequenceData(data: unknown, threadId: string): void { } } +function handleProvisionalModelData( + data: unknown, + setProvisionalModel: Dispatch>, +): void { + const parsed = CHEATCODE_DATA_SCHEMAS["model-provisional"].safeParse(data); + if (!parsed.success || parsed.data.phase === "reset") { + if (parsed.success) setProvisionalModel(EMPTY_PROVISIONAL_MODEL); + return; + } + const delta = parsed.data; + setProvisionalModel((current) => + current.streamId === delta.streamId + ? { streamId: current.streamId, text: current.text + delta.delta } + : { streamId: delta.streamId, text: delta.delta }, + ); +} + function handleProjectCreatedData( data: unknown, input: Parameters[0], @@ -425,10 +492,12 @@ function handleProjectCreated( } function handleStreamFinish(isError: boolean, input: Parameters[0]): void { + recordRunTiming(input.runTimingRef.current, "run_stream_finished"); if (!isError) { input.pendingSubmissionRef.current = null; } input.hasSubmittedRef.current = false; + input.setProvisionalModel(EMPTY_PROVISIONAL_MODEL); input.setRunStartedAt(null); for (const queryKey of [threadKeys.detail(input.threadId), threadKeys.messages(input.threadId)]) { void input.queryClient.invalidateQueries({ queryKey }); @@ -436,6 +505,23 @@ function handleStreamFinish(isError: boolean, input: Parameters>[0]): boolean { + return part.type === "data-sandbox-status" || part.type === "data-error"; +} + +function startRunTiming(state: RunTimingState, startedAt: number): void { + state.startedAt = startedAt; + state.reported.clear(); +} + +function recordRunTiming(state: RunTimingState, metric: RunTimingMetric): void { + if (state.startedAt === null || state.reported.has(metric)) { + return; + } + state.reported.add(metric); + reportBrowserPerformanceMetric(metric, Math.max(0, Date.now() - state.startedAt)); +} + function useOlderMessageLoader( onLoadOlderMessages: () => Promise, messages: readonly CheatcodeUIMessage[], diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx index f93cf285..65b455b2 100644 --- a/apps/web/src/components/chat/chat-panel.tsx +++ b/apps/web/src/components/chat/chat-panel.tsx @@ -39,6 +39,7 @@ export function ChatPanel(props: ChatPanelProps) { messages={controller.state.messages} onContinue={controller.actions.continueRun} onLoadOlderMessages={controller.actions.loadOlderMessages} + provisionalText={controller.state.provisionalText} runStartedAt={controller.state.runStartedAt} threadId={props.threadId} /> diff --git a/apps/web/src/components/chat/chat-transport.ts b/apps/web/src/components/chat/chat-transport.ts index 5bbfcd52..fe3fa4fe 100644 --- a/apps/web/src/components/chat/chat-transport.ts +++ b/apps/web/src/components/chat/chat-transport.ts @@ -22,12 +22,13 @@ interface ReconnectRequest { export function createChatTransport( threadId: string, getToken: () => Promise, + onRunResponseHeaders?: () => void, ): CheatcodeChatTransport { let cursorSource = () => "0"; const encodedThreadId = encodeURIComponent(threadId); const transport = new DefaultChatTransport({ api: gatewayRequestUrl(`/v1/threads/${encodedThreadId}/runs`), - fetch: createBoundedChatFetch(getToken), + fetch: createBoundedChatFetch(getToken, onRunResponseHeaders), prepareReconnectToStreamRequest: async (): Promise => { const cursor = cursorSource(); if (cursor !== "0") { @@ -89,7 +90,10 @@ export function chatErrorMessage(message: string): string { return hint ? `${parsedResponse.data.error.message}. ${hint}` : parsedResponse.data.error.message; } -function createBoundedChatFetch(getToken: () => Promise) { +function createBoundedChatFetch( + getToken: () => Promise, + onRunResponseHeaders?: () => void, +) { return async (input: RequestInfo | URL, init: RequestInit = {}): Promise => { const timeout = AbortSignal.timeout(CHAT_AUTH_TIMEOUT_MS); const authSignal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; @@ -100,6 +104,9 @@ function createBoundedChatFetch(getToken: () => Promise) { const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${token}`); const response = await globalThis.fetch(input, { ...init, headers }); + if (init.method?.toUpperCase() === "POST") { + onRunResponseHeaders?.(); + } if (response.ok) { return response; } diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index 68882ec4..547d0fe4 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -24,6 +24,7 @@ interface MessageListProps { messages: readonly CheatcodeUIMessage[]; onContinue: () => void; onLoadOlderMessages: () => Promise; + provisionalText: string; runStartedAt: null | number; threadId: string; } @@ -36,11 +37,17 @@ export function MessageList({ messages, onContinue, onLoadOlderMessages, + provisionalText, runStartedAt, threadId, }: MessageListProps) { const scrollState = useMessageScrollState(); - const displayMessages = withPendingAssistant(messages, isWaitingForFirstResponse, threadId); + const displayMessages = withLiveAssistant( + messages, + isWaitingForFirstResponse, + provisionalText, + threadId, + ); const turns = groupMessagesIntoTurns(displayMessages); const virtualizer = useVirtualizer({ count: turns.length, @@ -83,11 +90,15 @@ export function MessageList({ ); } -function withPendingAssistant( +function withLiveAssistant( messages: readonly CheatcodeUIMessage[], isWaitingForFirstResponse: boolean, + provisionalText: string, threadId: string, ): readonly CheatcodeUIMessage[] { + if (provisionalText.length > 0) { + return withProvisionalText(messages, provisionalText, threadId); + } if (!isWaitingForFirstResponse) { return messages; } @@ -100,3 +111,19 @@ function withPendingAssistant( }, ]; } + +function withProvisionalText( + messages: readonly CheatcodeUIMessage[], + text: string, + threadId: string, +): readonly CheatcodeUIMessage[] { + const last = messages.at(-1); + const part = { state: "streaming" as const, text, type: "text" as const }; + if (last?.role === "assistant") { + return [...messages.slice(0, -1), { ...last, parts: [...last.parts, part] }]; + } + return [ + ...messages, + { id: `provisional-assistant-${threadId}`, parts: [part], role: "assistant" }, + ]; +} diff --git a/apps/web/src/components/chat/use-chat-submission.ts b/apps/web/src/components/chat/use-chat-submission.ts index 6a717adf..6cd2dd75 100644 --- a/apps/web/src/components/chat/use-chat-submission.ts +++ b/apps/web/src/components/chat/use-chat-submission.ts @@ -32,6 +32,7 @@ interface ChatSubmissionInput { selectedModel: null | string; sendMessage: SendMessage; setDraft: (threadId: string, value: string) => void; + startRunTiming: (startedAt: number) => void; setRunStartedAt: (value: number) => void; status: ChatStatus; threadId: string; @@ -80,6 +81,7 @@ export function useChatSubmission(input: ChatSubmissionInput): { input.hasReceivedStreamDataRef.current = false; const pending = pendingSubmission(messageId, text, false); input.pendingSubmissionRef.current = pending; + input.startRunTiming(pending.submittedAt); input.setRunStartedAt(pending.submittedAt); void input.sendMessage(userMessage(messageId, text, null), modelBody(input.selectedModel, {})); }, [input]); @@ -99,6 +101,7 @@ function submitInCurrentThread( const messageId = crypto.randomUUID(); const pending = pendingSubmission(messageId, text, true); input.pendingSubmissionRef.current = pending; + input.startRunTiming(pending.submittedAt); input.setRunStartedAt(pending.submittedAt); void input.sendMessage( userMessage(messageId, text, selection.intent ?? null), diff --git a/apps/web/src/components/preview/use-ensure-preview-live.ts b/apps/web/src/components/preview/use-ensure-preview-live.ts index a7d3c1a0..5793754c 100644 --- a/apps/web/src/components/preview/use-ensure-preview-live.ts +++ b/apps/web/src/components/preview/use-ensure-preview-live.ts @@ -14,6 +14,7 @@ export interface PreviewLiveState { } const STATUS_POLL_MS = 20_000; +const STATUS_POLL_MAX_MS = 60_000; const STATUS_REQUEST_TIMEOUT_MS = 15_000; const PREVIEW_SESSION_CHECK_MS = 60_000; const PREVIEW_SESSION_REFRESH_MS = 8 * 60 * 1000; @@ -242,24 +243,28 @@ function usePreviewStatusPolling( if (!active || !threadId) return; const controller = new AbortController(); let polling = false; + let pollDelay = STATUS_POLL_MS; + let timer: ReturnType | undefined; const poll = async () => { if (polling) return; polling = true; try { - await pollPreviewStatus({ + const didRecover = await pollPreviewStatus({ getToken: runtime.deps.getToken, isWaking: () => runtime.refreshRequest !== null, signal: controller.signal, threadId, wake, }); + pollDelay = didRecover ? STATUS_POLL_MS : Math.min(pollDelay * 2, STATUS_POLL_MAX_MS); } finally { polling = false; + if (!controller.signal.aborted) timer = setTimeout(() => void poll(), pollDelay); } }; - const id = setInterval(() => void poll(), STATUS_POLL_MS); + timer = setTimeout(() => void poll(), pollDelay); return () => { - clearInterval(id); + if (timer) clearTimeout(timer); controller.abort(); }; }, [active, runtime, threadId, wake]); @@ -314,7 +319,7 @@ async function pollPreviewStatus(input: { signal: AbortSignal; threadId: string; wake: () => Promise; -}): Promise { +}): Promise { try { const status = await getSandboxPreviewStatus( input.getToken, @@ -323,12 +328,15 @@ async function pollPreviewStatus(input: { ); if (!input.signal.aborted && previewStatusNeedsWake(status) && !input.isWaking()) { await input.wake(); + return true; } + return false; } catch (error) { if (isAbortError(error)) { - return; + return false; } // Best-effort; the wake path surfaces hard failures. + return false; } } diff --git a/apps/web/src/components/projects/projects-shell.tsx b/apps/web/src/components/projects/projects-shell.tsx index 352a7b75..14e619ea 100644 --- a/apps/web/src/components/projects/projects-shell.tsx +++ b/apps/web/src/components/projects/projects-shell.tsx @@ -278,13 +278,22 @@ function useThreadQuery(getToken: () => Promise, threadId: null | enabled: Boolean(threadId), queryFn: ({ signal }) => getThread(getToken, String(threadId), signal), queryKey: threadKeys.detail(threadId), - refetchInterval: (query) => (query.state.data?.activeRunId ? 2_000 : false), + refetchInterval: (query) => activeRunRecoveryInterval(query.state), refetchIntervalInBackground: false, retry: false, staleTime: 5_000, }); } +function activeRunRecoveryInterval(state: { + data?: Thread | undefined; + dataUpdateCount: number; +}): false | number { + if (!state.data?.activeRunId) return false; + const exponent = Math.max(0, Math.min(state.dataUpdateCount - 1, 3)); + return Math.min(2_000 * 2 ** exponent, 15_000); +} + function useReconcileTerminalRun(input: { activeRunId: null | string; queryClient: ReturnType; diff --git a/apps/web/src/components/shell/sidebar-chat-list.tsx b/apps/web/src/components/shell/sidebar-chat-list.tsx index f89fe376..1bd6d972 100644 --- a/apps/web/src/components/shell/sidebar-chat-list.tsx +++ b/apps/web/src/components/shell/sidebar-chat-list.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; -import type { SidebarChat, useSidebarChats } from "@/components/shell/sidebar-data"; +import type { SidebarChat, SidebarChatCollection } from "@/components/shell/sidebar-data"; import { SidebarDeleteDialog, SidebarInlineRenameInput, @@ -28,7 +28,7 @@ export function ChatList({ chats, }: { activeThreadId: string | null; - chats: ReturnType; + chats: SidebarChatCollection; }) { const actions = useChatActions(activeThreadId); if (chats.isLoading) return ; @@ -60,7 +60,7 @@ function ChatRows({ }: { actions: ReturnType; activeThreadId: string | null; - chats: ReturnType["items"]; + chats: SidebarChatCollection["items"]; }) { return (
diff --git a/apps/web/src/components/shell/sidebar-controller.ts b/apps/web/src/components/shell/sidebar-controller.ts index 8a818828..37eeffec 100644 --- a/apps/web/src/components/shell/sidebar-controller.ts +++ b/apps/web/src/components/shell/sidebar-controller.ts @@ -6,12 +6,7 @@ import type { Dispatch, SetStateAction } from "react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import type { AuthMode } from "@/components/auth/auth-modal"; -import { - type SidebarProject, - useActiveProjectId, - useSidebarChats, - useSidebarProjects, -} from "@/components/shell/sidebar-data"; +import { type SidebarProject, useSidebarBootstrap } from "@/components/shell/sidebar-data"; import { activeChatIdFromPathname } from "@/components/shell/sidebar-navigation-model"; import { updateProject } from "@/lib/api/project-thread"; import { invalidateChatLists } from "@/lib/api/query-keys"; @@ -43,13 +38,13 @@ export function useSidebarNavigationData({ }) { const { getToken } = useAuth(); const activeThreadId = activeChatIdFromPathname(pathname); - const activeProjectId = useActiveProjectId(getToken, activeThreadId, isSignedIn); + const bootstrap = useSidebarBootstrap(getToken, activeThreadId, isSignedIn); return { - activeProjectId, + activeProjectId: bootstrap.activeProjectId, activeThreadId, renameMutation: useProjectRenameMutation(getToken), - sidebarChats: useSidebarChats(getToken, isSignedIn), - sidebarProjects: useSidebarProjects(getToken, isSignedIn, activeProjectId), + sidebarChats: bootstrap.sidebarChats, + sidebarProjects: bootstrap.sidebarProjects, }; } diff --git a/apps/web/src/components/shell/sidebar-data.ts b/apps/web/src/components/shell/sidebar-data.ts index d0e6811a..8d11109e 100644 --- a/apps/web/src/components/shell/sidebar-data.ts +++ b/apps/web/src/components/shell/sidebar-data.ts @@ -1,15 +1,9 @@ "use client"; -import type { ProjectSummary, Thread } from "@cheatcode/types/api"; -import { useQueries, useQuery } from "@tanstack/react-query"; -import { - getProject, - getThread, - listProjectsPage, - listProjectThreadsPage, - listRecentThreads, -} from "@/lib/api/project-thread"; -import { projectKeys, sidebarKeys, threadKeys } from "@/lib/api/query-keys"; +import type { NavigationBootstrapProject, SearchResultThread } from "@cheatcode/types/api"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { getNavigationBootstrap } from "@/lib/api/project-thread"; +import { sidebarKeys } from "@/lib/api/query-keys"; export interface SidebarChat { activeRunId: string | null; @@ -24,93 +18,79 @@ export interface SidebarProject { name: string; } -export function useSidebarChats(getToken: () => Promise, enabled: boolean) { - const { data, isPending } = useQuery({ - enabled, - queryFn: ({ signal }) => listRecentThreads(getToken, 20, signal), - queryKey: sidebarKeys.chats, - retry: false, - staleTime: 30_000, - }); - return { - isLoading: enabled && isPending, - items: enabled ? (data ?? []) : [], - }; +export interface SidebarChatCollection { + isLoading: boolean; + items: SidebarChat[]; } -export function useActiveProjectId( - getToken: () => Promise, - threadId: string | null, - enabled: boolean, -): string | null { - const threadQuery = useQuery({ - enabled: enabled && Boolean(threadId), - queryFn: ({ signal }) => getThread(getToken, String(threadId), signal), - queryKey: threadKeys.detail(threadId), - retry: false, - staleTime: 5_000, - }); - return threadQuery.data?.projectId ?? null; +export interface SidebarProjectCollection { + isLoading: boolean; + items: SidebarProject[]; } -export function useSidebarProjects( +export function useSidebarBootstrap( getToken: () => Promise, + activeThreadId: string | null, enabled: boolean, - activeProjectId: string | null, ) { - const projectsQuery = useQuery({ + const query = useQuery({ enabled, - queryFn: ({ signal }) => listProjectsPage(getToken, null, 6, signal), - queryKey: sidebarKeys.projectFirstPage, + placeholderData: keepPreviousData, + queryFn: ({ signal }) => getNavigationBootstrap(getToken, activeThreadId, signal), + queryKey: sidebarKeys.bootstrapFor(activeThreadId), retry: false, staleTime: 30_000, }); - const activeProjectQuery = useQuery({ - enabled: enabled && Boolean(activeProjectId), - queryFn: ({ signal }) => getProject(getToken, String(activeProjectId), signal), - queryKey: projectKeys.detail(activeProjectId), - retry: false, - staleTime: 5_000, - }); - const projects = projectsWithActive( - enabled ? (projectsQuery.data?.data ?? []) : [], - activeProjectQuery.data ?? null, - ).slice(0, 6); - const threadQueries = useQueries({ - queries: projects.map((project) => ({ - enabled: enabled && projectsQuery.isSuccess, - queryFn: ({ signal }) => listProjectThreadsPage(getToken, project.id, null, 1, signal), - queryKey: sidebarKeys.projectThreadsFor(project.id), - retry: false, - staleTime: 30_000, - })), - }); - const items = projects.map((project, index) => - sidebarProjectFromApi(project, threadQueries[index]?.data?.data[0] ?? null), - ); - + const recentThreads = enabled ? (query.data?.recentThreads ?? []) : []; return { - isLoading: - enabled && - (projectsQuery.isPending || - (Boolean(activeProjectId) && activeProjectQuery.isPending) || - threadQueries.some((query) => query.isPending)), - items, + activeProjectId: activeProjectForThread( + recentThreads, + activeThreadId, + query.isPlaceholderData ? null : (query.data?.activeProjectId ?? null), + ), + sidebarChats: sidebarChats(recentThreads, enabled && query.isPending), + sidebarProjects: sidebarProjects( + enabled ? (query.data?.projects ?? []) : [], + enabled && query.isPending, + ), }; } -function sidebarProjectFromApi(project: ProjectSummary, newest: Thread | null): SidebarProject { +function activeProjectForThread( + recentThreads: readonly SearchResultThread[], + activeThreadId: string | null, + resolvedActiveProjectId: string | null, +): string | null { + if (!activeThreadId) return null; + const recentThread = recentThreads.find((thread) => thread.id === activeThreadId); + return recentThread ? recentThread.projectId : resolvedActiveProjectId; +} + +function sidebarChats( + recentThreads: readonly SearchResultThread[], + isLoading: boolean, +): SidebarChatCollection { return { - href: newest ? `/chats/${encodeURIComponent(newest.id)}` : null, - id: project.id, - name: project.name, + isLoading, + items: recentThreads.map((thread) => ({ + activeRunId: thread.activeRunId, + id: thread.id, + projectId: thread.projectId, + title: thread.title, + })), }; } -function projectsWithActive( - projects: readonly ProjectSummary[], - activeProject: ProjectSummary | null, -): ProjectSummary[] { - if (!activeProject) return [...projects]; - return [activeProject, ...projects.filter((project) => project.id !== activeProject.id)]; +function sidebarProjects( + projects: readonly NavigationBootstrapProject[], + isLoading: boolean, +): SidebarProjectCollection { + return { + isLoading, + items: projects.map((project) => ({ + href: project.latestThreadId ? `/chats/${encodeURIComponent(project.latestThreadId)}` : null, + id: project.id, + name: project.name, + })), + }; } diff --git a/apps/web/src/components/shell/sidebar-expanded-navigation.tsx b/apps/web/src/components/shell/sidebar-expanded-navigation.tsx index 274b914e..8403a5ce 100644 --- a/apps/web/src/components/shell/sidebar-expanded-navigation.tsx +++ b/apps/web/src/components/shell/sidebar-expanded-navigation.tsx @@ -8,9 +8,9 @@ import type { } from "@/components/shell/sidebar.types"; import { ChatList } from "@/components/shell/sidebar-chat-list"; import type { + SidebarChatCollection, SidebarProject, - useSidebarChats, - useSidebarProjects, + SidebarProjectCollection, } from "@/components/shell/sidebar-data"; import { SidebarChatsIcon, SidebarProjectsIcon } from "@/components/shell/sidebar-nav-icons"; import { @@ -42,8 +42,8 @@ interface SidebarNavigationProps { pathname: string; projectsOpen: boolean; renameMutation: ProjectRenameMutationState; - sidebarChats: ReturnType; - sidebarProjects: ReturnType; + sidebarChats: SidebarChatCollection; + sidebarProjects: SidebarProjectCollection; } export function SidebarMainNavigation(props: SidebarNavigationProps) { diff --git a/apps/web/src/components/shell/sidebar-project-list.tsx b/apps/web/src/components/shell/sidebar-project-list.tsx index 78b45ed0..ca3b24c4 100644 --- a/apps/web/src/components/shell/sidebar-project-list.tsx +++ b/apps/web/src/components/shell/sidebar-project-list.tsx @@ -7,7 +7,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import type { ProjectRenameMutationState } from "@/components/shell/sidebar.types"; -import type { SidebarProject, useSidebarProjects } from "@/components/shell/sidebar-data"; +import type { SidebarProject, SidebarProjectCollection } from "@/components/shell/sidebar-data"; import { SidebarDeleteDialog, SidebarInlineRenameInput, @@ -33,7 +33,7 @@ export function ProjectList({ }: { activeProjectId: string | null; onRename: (project: SidebarProject, name: string) => void; - projects: ReturnType; + projects: SidebarProjectCollection; renameMutation: ProjectRenameMutationState; }) { const actions = useProjectActions(activeProjectId); @@ -179,6 +179,7 @@ function useProjectChatMutation({ onError: (error) => toast.error(error instanceof Error ? error.message : "Couldn't start a chat"), onSuccess: (thread) => { + void queryClient.invalidateQueries({ queryKey: sidebarKeys.bootstrap }); void queryClient.invalidateQueries({ queryKey: sidebarKeys.projectThreads }); void queryClient.invalidateQueries({ queryKey: sidebarKeys.chats }); routerPush(`/chats/${encodeURIComponent(thread.id)}`); diff --git a/apps/web/src/components/shell/sidebar.types.ts b/apps/web/src/components/shell/sidebar.types.ts index d7e4c642..9da4d8f5 100644 --- a/apps/web/src/components/shell/sidebar.types.ts +++ b/apps/web/src/components/shell/sidebar.types.ts @@ -1,8 +1,8 @@ import type { AuthMode } from "@/components/auth/auth-modal"; import type { + SidebarChatCollection, SidebarProject, - useSidebarChats, - useSidebarProjects, + SidebarProjectCollection, } from "@/components/shell/sidebar-data"; export type SidebarBooleanUpdater = (updater: (current: boolean) => boolean) => void; @@ -36,7 +36,7 @@ export interface ExpandedSidebarContentProps { projectsOpen: boolean; renameMutation: ProjectRenameMutationState; settingsOpen: boolean; - sidebarChats: ReturnType; - sidebarProjects: ReturnType; + sidebarChats: SidebarChatCollection; + sidebarProjects: SidebarProjectCollection; signOut: () => void; } diff --git a/apps/web/src/lib/api/project-thread.ts b/apps/web/src/lib/api/project-thread.ts index 3eb4f82a..a0eb36d2 100644 --- a/apps/web/src/lib/api/project-thread.ts +++ b/apps/web/src/lib/api/project-thread.ts @@ -2,15 +2,15 @@ import { CHEATCODE_DATA_SCHEMAS, type CheatcodeUIMessage, toAgentRunId } from "@cheatcode/types"; import { + type NavigationBootstrapResponse, + NavigationBootstrapResponseSchema, Paginated, type ProjectSummary, ProjectSummarySchema, - RecentThreadsResponseSchema, type SandboxPreviewStatus, SandboxPreviewStatusSchema, type SandboxPreviewWake, SandboxPreviewWakeSchema, - type SearchResultThread, type Thread, type ThreadMessage, ThreadMessageSchema, @@ -37,6 +37,23 @@ const PROJECT_ARCHIVE_CONTENT_TYPES = new Set([ "application/zip", ]); +/** Loads the sidebar's bounded navigation snapshot with one authenticated request. */ +export async function getNavigationBootstrap( + getToken: () => Promise, + activeThreadId: string | null, + signal?: AbortSignal, +): Promise { + const query = activeThreadId ? `?activeThreadId=${encodeURIComponent(activeThreadId)}` : ""; + const response = await authorizedFetch( + getToken, + `/v1/bootstrap${query}`, + signal ? { signal } : {}, + ); + return NavigationBootstrapResponseSchema.parse( + await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.collections), + ); +} + export interface CursorPage { data: T[]; has_more: boolean; @@ -152,22 +169,6 @@ export async function listProjectThreadsPage( ); } -/** The user's recent chats (threads) across all projects, newest first — chat-first sidebar. */ -export async function listRecentThreads( - getToken: () => Promise, - limit = 20, - signal?: AbortSignal, -): Promise { - const response = await authorizedFetch( - getToken, - `/v1/threads?limit=${limit}`, - signal ? { signal } : {}, - ); - return RecentThreadsResponseSchema.parse( - await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.metadata), - ).threads; -} - export async function getProject( getToken: () => Promise, projectId: string, diff --git a/apps/web/src/lib/api/query-keys.ts b/apps/web/src/lib/api/query-keys.ts index edcc9073..d342aa59 100644 --- a/apps/web/src/lib/api/query-keys.ts +++ b/apps/web/src/lib/api/query-keys.ts @@ -12,6 +12,8 @@ export const projectKeys = { }; export const sidebarKeys = { + bootstrap: ["sidebar-bootstrap"] as const, + bootstrapFor: (threadId: string | null) => ["sidebar-bootstrap", threadId] as const, chats: ["sidebar-chats"] as const, projectFirstPage: ["sidebar-projects", "first-page"] as const, projectPicker: ["sidebar-projects", "picker"] as const, @@ -22,6 +24,7 @@ export const sidebarKeys = { export async function invalidateChatLists(queryClient: QueryClient): Promise { await Promise.all([ + queryClient.invalidateQueries({ queryKey: sidebarKeys.bootstrap }), queryClient.invalidateQueries({ queryKey: sidebarKeys.chats }), queryClient.invalidateQueries({ queryKey: sidebarKeys.projectThreads }), queryClient.invalidateQueries({ queryKey: sidebarKeys.projects }), diff --git a/apps/web/src/lib/rum.ts b/apps/web/src/lib/rum.ts index e12f8a64..1cba5c5f 100644 --- a/apps/web/src/lib/rum.ts +++ b/apps/web/src/lib/rum.ts @@ -1,6 +1,6 @@ "use client"; -import { normalizeTelemetryPath } from "@cheatcode/types"; +import { type BrowserPerformanceMetricNameSchema, normalizeTelemetryPath } from "@cheatcode/types"; import { type MetricWithAttribution, onCLS, @@ -9,11 +9,15 @@ import { onLCP, onTTFB, } from "web-vitals/attribution"; +import type { z } from "zod"; import { gatewayRequestUrl } from "@/lib/api/gateway-url"; const VITALS_ENDPOINT = gatewayRequestUrl("/v1/vitals"); const queue = new Set(); let initialized = false; +let flushTimer: ReturnType | null = null; + +export type BrowserPerformanceMetricName = z.infer; interface WebVitalPayload { attributionTarget?: string; @@ -46,6 +50,34 @@ export function initWebVitals(): void { addEventListener("pagehide", flush); } +/** Records an anonymous, bounded product milestone using the Web Vitals delivery path. */ +export function reportBrowserPerformanceMetric( + name: Exclude, + value: number, +): void { + if (!Number.isFinite(value) || value < 0) { + return; + } + queue.add({ + delta: value, + id: `${name}-${Date.now().toString(36)}-${crypto.randomUUID()}`, + name, + url: normalizeTelemetryPath(window.location.pathname), + value, + }); + scheduleFlush(); +} + +function scheduleFlush(): void { + if (flushTimer !== null) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flush(); + }, 1_000); +} + function report(metric: MetricWithAttribution): void { const target = attributionTarget(metric); queue.add({ @@ -61,6 +93,10 @@ function report(metric: MetricWithAttribution): void { } function flush(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } if (queue.size === 0) { return; } diff --git a/biome.jsonc b/biome.jsonc index 872f17e7..9f172b41 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -157,6 +157,7 @@ "**/drizzle.config.ts", "**/commitlint.config.js", "apps/agent-worker/src/index.ts", + "apps/artifact-worker/src/index.ts", "apps/gateway-worker/src/index.ts", "apps/preview-proxy/src/index.ts", "apps/webhooks-worker/src/index.ts" diff --git a/docs/plans/cloudflare-critical-path-performance.md b/docs/plans/cloudflare-critical-path-performance.md new file mode 100644 index 00000000..a32a609f --- /dev/null +++ b/docs/plans/cloudflare-critical-path-performance.md @@ -0,0 +1,835 @@ +# Cloudflare Critical-Path Performance Initiative + +## Overview + +Improve Cheatcode's signed-in startup, agent time-to-first-token, Worker cold-start behavior, +database path, and generated-output delivery without moving the Next.js frontend from Vercel. + +The frontend remains on Vercel because the measured frontend path is already fast and Cheatcode is +using the native Next.js platform. The work below removes avoidable hops and buffering in the +Cloudflare request path, where the larger gains are available. + +This is an initiative composed of independently deployable pull requests. Do not combine it into a +single release. Phase 0 establishes comparable measurements; every later phase has its own feature +flag, canary, or additive rollback path. + +### In scope + +- End-to-end latency and product-milestone telemetry. +- Safe `Server-Timing`/`Timing-Allow-Origin` response telemetry and production percentile dashboards. +- One request-scoped authenticated principal in the Gateway. +- Cloudflare native rate limiting for availability-friendly read paths. +- A bounded, set-based sidebar bootstrap endpoint. +- A DNS-only Vercel apex with an explicit browser preconnect to the Gateway. +- True, reconnectable model token streaming with Workflow retry safety. +- Agent Worker startup and bundle profiling, then evidence-backed reduction. +- Hyperdrive endpoint/configuration reconciliation. +- Smart Placement canaries. +- R2 range and conditional delivery for generated outputs. +- Event-driven or backed-off active-run and preview status refresh. +- An explicit Cloudflare cache matrix and zone-setting drift control in approved IaC. + +### Out of scope + +- Migrating or proxying the Vercel frontend through Cloudflare. +- Low-priority minification, Early Hints, speculative loading, or dashboard-only toggles. +- Hyperdrive query caching for tenant-scoped application queries. +- Changing database vendors, auth providers, agent frameworks, or sandbox providers. +- Hard token, step, or cost limits in the semantic agent loop. +- Replacing Durable Objects on strict write, quota, idempotency, or run-stream paths. +- Shared caching of private outputs, arbitrary user previews, or authenticated Gateway responses. + +## Desired flow + +```mermaid +flowchart LR + B[Browser] --> V[Vercel Next.js shell] + V --> G[Cloudflare Gateway] + + G --> A[Request-scoped principal] + A --> RL{Route policy} + RL -->|cheap/public read| NRL[Native rate limit] + RL -->|write/expensive/global| DORL[RateLimiter Durable Object] + + NRL --> BS[Navigation bootstrap] + DORL --> BS + BS --> HD[Hyperdrive] + HD --> PG[Supabase Postgres + RLS] + + DORL --> AW[Agent Worker admission] + AW --> AR[AgentRun Durable Object] + AR --> WF[Cloudflare Workflow] + WF --> LLM[Model provider stream] + LLM -->|first chunk immediately; later chunks coalesced| AR + AR -->|sequenced, persisted, reconnectable stream| G + G --> B + + G --> R2[R2 generated outputs] + R2 -->|206 Range / conditional ETag / no-store| B +``` + +## Success criteria + +Phase 0 records the production baseline and freezes exact absolute SLOs. Until then, use these +relative release gates: + +| Area | Release gate | +|---|---| +| Signed-in navigation | Sidebar critical requests fall from 8-12 to 1; database statement count is bounded independently of project count; warm p75 improves by at least 40%. | +| Initial API fan-out | No more than three authenticated requests are needed for the initial signed-in surface; only one is navigation-critical. | +| Gateway auth | Clerk JWT verification, secret resolution, and internal-user resolution each run at most once per request. | +| Cheap reads | No RateLimiter Durable Object call; rejected reads remain safe and observable. | +| Run admission | Initial targets are create-run headers below 500 ms p75 and first status below 750 ms p75; Phase 0 may refine them only with documented production evidence. | +| Model TTFT | First non-empty provider text reaches the AgentRun stream within 100 ms of receipt at the Worker at p75, excluding provider latency. | +| User-perceived TTFT | Initial target is first actual model token below 2.5 s p75, segmented by provider/model/region rather than blended globally. | +| Stream correctness | Reconnect and Workflow retry never duplicate or concatenate two visible generations for the same model turn. | +| Agent startup | Startup falls below 200 ms or improves at least 25% from the measured baseline; compressed upload size does not regress more than 10%. | +| Hyperdrive/placement | Candidate improves p75 by at least 10%, p95 regresses no more than 5%, and error/RLS correctness is unchanged. | +| Output delivery | Valid single byte ranges return 206; matching ETags return 304; invalid ranges return 416; authorization remains unchanged. | +| Reliability | No statistically meaningful increase in 4xx/5xx, canceled-run leakage, connection errors, or stream-reconnect failures. | + +The initial measured reference values are a 308 ms local Agent Worker startup, a 2.47 MB gzip +Agent Worker upload, and an 8-12 request signed-in sidebar fan-out. Production latency must be +remeasured in Phase 0 rather than treating local checks as production SLOs. + +## UX and behavioral compatibility invariants + +These optimizations may change latency and transport internals, but they must not change what users +can do, what data they see, or how existing workflows behave. + +1. **No visual redesign.** Navigation structure, copy, controls, responsive behavior, accessibility, + loading/error affordances, and user interaction semantics remain unchanged unless a separate UX + change is explicitly approved. +2. **No auth, billing, permission, or tenant-boundary change.** Clerk behavior, verified-email + requirements, plan entitlements, forced RLS, signed capabilities, and deletion/revocation + semantics must remain equivalent or stricter. +3. **Additive APIs first.** New bootstrap/status/artifact paths ship beside current endpoints. The web + client switches only after schema, response, empty/error state, and authorization parity is + demonstrated. Old paths remain available through the rollout and rollback window. +4. **Agent semantics stay identical.** Model selection, prompts, tools, fallback eligibility, + semantic-completion loop, tool ordering, cancellation, and persisted conversation content do not + change. Streaming changes when text is delivered, not what the model/tool loop is asked to do. +5. **Exactly-once visible stream behavior.** Status, text, tool, error, and completion events keep + their current meaning and order. Reconnects resume from the stored cursor without missing or + duplicating visible content. +6. **State must converge after disruption.** Sidebar running indicators, active-run state, preview + readiness, artifacts, and terminal messages recover after refresh, reconnect, Worker restart, or + a missed event. Event-driven paths retain a bounded fallback read/backoff path. +7. **Private data remains revocable.** Private outputs stay `private, no-store`; Range and ETag + support must still pass signature and current DB ownership checks before `200`, `206`, or `304`. + Browser/shared caching must not allow a deleted output to remain accessible. +8. **No legitimate-user rate-limit regression.** Native cheap-read limits launch with sufficient + headroom and fail open on binding failure. If 429 rate, route behavior, or a documented header + contract regresses, that route stays on the existing Durable Object limiter. +9. **Performance is not a substitute for reliability.** A phase does not ship merely because its + median is faster; p95, errors, completion rate, cancel success, reconnect success, and core UX + flows must meet the non-regression gates. +10. **Every behavioral change has a kill switch or additive rollback.** A user cohort can return to + the prior path without a data migration, transcript rewrite, or destructive operation. + +## Decisions + +1. **Keep Vercel for the frontend.** Revisit only if a future platform requirement cannot be met on + Vercel or a production A/B test shows a material end-user gain. +2. **Keep strict coordination in Durable Objects.** Native rate limits protect cheap reads only; + writes, expensive reads, run creation, quota, idempotency, and global coordination keep their + existing strong path. +3. **Do not cache tenant SQL in Hyperdrive.** Signed transaction-local RLS context makes generic + query caching an unsafe boundary. +4. **Make bootstrap additive.** Existing project/thread/search endpoints remain available for other + consumers and instant rollback. +5. **Stream through AgentRun, not directly from Workflow to the client.** AgentRun remains the owner + of ordering, persistence, authorization, cancellation, and reconnect cursors. +6. **Flush the first text immediately; coalesce only subsequent tiny deltas.** Use a maximum 25 ms + interval or 512 UTF-8 bytes, whichever occurs first, and retain the existing per-event byte cap. +7. **Never restart generation after visible output.** Fallback/retry is allowed before the first + visible delta. After a visible delta, fail the turn with the partial output intact rather than + concatenating a second nondeterministic answer. +8. **Profile before splitting the Agent Worker.** A Worker boundary move is conditional on measured + import/startup evidence and requires a separate architecture decision. +9. **Canary Hyperdrive and Smart Placement independently.** Do not combine them in one experiment; + otherwise their effects cannot be attributed or safely rolled back. +10. **Private artifact responses remain `no-store`.** Range and ETag/conditional support reduce + transfer and enable resumable clients without weakening deletion/revocation behavior. Any future + browser-private caching requires a separate security and UX decision. +11. **Keep the Vercel apex DNS-only.** Do not orange-cloud the frontend merely to create a + same-origin API. Keep the existing one-day preflight cache and preconnect the browser to the + Gateway origin. +12. **Keep greeting/weather outside critical bootstrap.** The selected strategy is one navigation + bootstrap, not a mega-bootstrap whose availability or latency depends on external weather data. +13. **Treat direct Supabase as the expected Hyperdrive production target.** Hyperdrive already + pools upstream connections. Verify the live endpoint and correctness, then align repository + setup/docs to direct; use a session-pooler canary only as a contingency if direct-path evidence + fails the release gate. +14. **Cache only dedicated public data.** Any stale-tolerant catalog/reference cache uses a separate + public Hyperdrive/Worker entrypoint; current tenant/RLS traffic remains cache-disabled. + +## Phase and PR sequence + +```mermaid +flowchart TD + P0[PR 1: instrumentation and baselines] --> P1[PR 2: request-scoped auth] + P1 --> P2[PR 3: native read rate limiting] + P1 --> P3[PR 4: navigation bootstrap] + P0 --> P4[PR 5: durable token streaming] + P0 --> P5[PR 6: Agent Worker startup reduction] + P0 --> P6[PR 7: Hyperdrive canaries] + P6 --> P7[PR 8: Smart Placement canaries] + P0 --> P8[PR 9: artifacts and polling removal] + P0 --> P9[PR 10: public caching and zone IaC] +``` + +PRs 3, 4, 5, 6, 9, and 10 can be developed independently after their dependencies land, but each must +be canaried and evaluated separately. Hyperdrive must be resolved before Smart Placement testing. + +## Phase 0: measurement foundation + +### Goal + +Make every optimization attributable by recording the latency segments that currently disappear +inside aggregate route duration. + +### Implementation + +1. Extend `packages/observability/src/analytics.ts` only where required; retain the existing + `PerformanceMetric` fields (`dbQueryMs`, `llmMs`, `queueWaitMs`, `sandboxMs`, `totalMs`, and + `ttftMs`) and add bounded milestone/variant fields rather than a new telemetry vendor. +2. Add a request-local timing collector in `packages/observability/src/worker-runtime.ts`. Emit one + final metric per request plus explicit milestones for: + - Gateway arrival; + - auth secret resolution; + - Clerk JWT verification; + - internal-user resolution/sync; + - rate-limit decision; + - Hyperdrive handle creation/acquisition; + - signed RLS transaction setup; + - database operation/query time; + - service-binding and Durable Object time; + - Workflow acceptance; + - response headers returned; + - first status event; + - provider request start and first actual non-empty model text; + - first persisted model-text chunk; + - final model token and durable run completion. + Include release SHA/Worker version, request colo, the safe `cf-placement` value when present, + reconnect/new-stream state, provider/model where applicable, and experiment variant. Do not use + a user ID as an Analytics Engine index for performance dashboards. +3. Thread the collector through `apps/gateway-worker/src/index.ts`, + `apps/gateway-worker/src/authenticate.ts`, `apps/gateway-worker/src/rate-limit.ts`, and + `packages/db/src/client.ts`. Keep user IDs, tokens, SQL text, credentials, and provider payloads + out of metrics. +4. Extend `apps/agent-worker/src/durable-objects/agent-run-performance.ts` so provider-first-text, + AgentRun-first-model-text, first status, final token, and completion are separate timestamps. + Status/error/sandbox chunks must not satisfy the model-text TTFT metric. Preserve a distinct + first-status metric for perceived responsiveness. +5. Extend `apps/web/src/lib/rum.ts` with product milestones: auth ready, shell interactive, + navigation data ready, browser receiving API headers, run submitted, stream connected, first + status, first model text, final text, and reconnect recovery. Continue sending Web Vitals through + the current Gateway endpoint. +6. Add a reproducible read-only performance script under `scripts/worker-performance-report.ts` + that runs Wrangler startup checks/dry-run bundles for all Workers and emits stable JSON plus a + human-readable table. Add a root package script without replacing the required verification + chain. +7. Add a safe `Server-Timing` allowlist for coarse phases such as edge handling, auth, admission, + and upstream wait. Add `Timing-Allow-Origin` for the canonical Vercel web origin. Never expose + tenant identity, SQL text, provider names/keys, internal object IDs, or security-sensitive timing + detail. Verify that streaming metrics measure body delivery separately from time-to-Response. +8. Build p50/p75/p95 dashboards from Analytics Engine and correlate them with automatic Workers + traces, Hyperdrive connection/query metrics, Durable Object metrics, and Workflow metrics. Every + chart must filter by release/variant and distinguish cold/warm, new/reconnect, colo/placement, + route, provider/model, and status class where applicable. +9. Document the metric names, dimensions, sampling, Server-Timing allowlist, dashboard queries, and + redaction contract in + `packages/observability/README.md` and the Gateway/Agent Worker READMEs. + +### Verification and rollout + +- Run the full repository gate chain. +- Exercise signed-in home, chat navigation, run creation, first model text, cancellation, and stream + reconnect with `agent-browser --auto-connect --session cheatcode-debug`. +- Inspect browser network/console and Workers Logs/Analytics Engine; verify one coherent trace ID + and no secrets or prompt content. Confirm browser Resource Timing can read the allowlisted server + phases because `Timing-Allow-Origin` is present. +- Collect at least 24 hours of production p50/p75/p95 by route, colo/region, release SHA, and Worker + version before freezing the absolute SLO dashboard. +- This phase is telemetry-only and rolls back by disabling emission/sampling, not by deleting the + schema. + +## Phase 1: request-scoped Gateway principal + +### Goal + +Resolve authenticated identity once per request and reuse it across route admission and optional +verified-email checks. + +### Implementation + +1. Introduce `apps/gateway-worker/src/auth-context.ts` with a strict request-scoped principal: + internal `UserId`, Clerk user ID, verified session claims, and lazy verified-primary-email state. + Store a memoized `Promise` in Hono variables; never use module scope. +2. Add the resolver to `GatewayVariables` in `apps/gateway-worker/src/gateway-env.ts` and initialize + it in the `/v1/*` middleware in `apps/gateway-worker/src/index.ts` next to the request-scoped + database handle. +3. Refactor `apps/gateway-worker/src/authenticate.ts` so: + - secret resolution and JWT verification happen once; + - internal-user lookup/lazy sync happens once; + - concurrent route consumers await the same promise; + - failures reject consistently and are not retried within the request; + - the existing deletion and lazy-sync semantics remain unchanged. +4. Refactor `apps/gateway-worker/src/agent-http-routes.ts` so create-run reuses verified JWT claims. + Call Clerk Backend API only for the primary-email status that is absent from trusted claims, and + memoize that lookup for the request. +5. Evaluate making verified-primary-email state webhook-synchronized. The current `users` schema + stores the primary email but not its verification state. If Clerk webhook payload ordering and + freshness can support an authoritative state, add an append-only DB migration and update + `apps/webhooks-worker/src/clerk.ts`; retain a bounded Clerk lookup for missing/stale state. Never + trust a stale local `true` after a newer Clerk event indicates otherwise. +6. Update each Gateway route collection and `apps/gateway-worker/src/agent-forwarding.ts` to read + the principal from context rather than independently authenticating. +7. Update `apps/gateway-worker/README.md` with the request identity lifecycle and explicitly state + that no cross-request auth cache exists. + +### Edge cases + +- Missing/expired tokens, key rotation, deleted Clerk users, first-request lazy sync, concurrent + principal consumers, and create-run with an unverified email. +- A failed verified-email lookup must not corrupt the already-authenticated principal. +- Out-of-order/duplicate Clerk webhooks and a local verification record older than the verified JWT + or Clerk user timestamp. +- Database handles still close exactly once in the outer middleware. + +### Acceptance + +- Instrumentation proves one JWT verification and one internal-user resolution per request. +- Route response/error contracts are unchanged. +- Create-run no longer verifies the JWT twice. +- Rollback is a code deploy; no schema or configuration migration is involved. + +## Phase 2: native rate limiting for cheap reads + +### Goal + +Remove the RateLimiter Durable Object hop from safe, high-volume read paths while preserving strict +coordination where correctness or cost requires it. This also removes dependence on the current +public Durable Object limiter's 256 geographically sticky shards for these approximate read limits. + +### Implementation + +1. Add Cloudflare rate-limit bindings for `publicRead` and `readCheap` in + `apps/gateway-worker/wrangler.jsonc`, using distinct namespaces and the current 60-second limits. +2. Add a `RateLimit` binding validator in `packages/env/src/worker-shared.ts`, wire it through + `packages/env/src/gateway-worker.ts`, and type both bindings in + `apps/gateway-worker/src/gateway-env.ts`. +3. Split `apps/gateway-worker/src/rate-limit.ts` into two explicit strategies: + - native, per-location approximate limiting for `publicRead` and `readCheap`; + - the existing Durable Object limiter for `publicWrite`, `readExpensive`, `runsCreate`, and + `writeNormal`. +4. Key authenticated native reads by internal user ID plus route class; key public reads by the + existing privacy-safe client identifier. Do not key on raw authorization headers. +5. Successful native-limited reads omit `RateLimit-Remaining` and `RateLimit-Reset`, because the + binding does not provide canonical global state. A rejected request returns the existing error + envelope, `429`, and `Retry-After`. Strict Durable Object routes keep their current canonical + headers. +6. Emit strategy, allow/reject, route class, and binding failure metrics. Preserve fail-open only for + cheap reads and fail-closed behavior for strict routes. +7. Update Gateway README and any API documentation that currently promises canonical rate-limit + headers on every response. + +### Acceptance + +- Cheap/public read traces contain no RateLimiter Durable Object segment. +- Write, expensive-read, run-creation, quota, and idempotency behavior is byte-for-byte compatible. +- Burst tests account for native per-location approximation and do not assert a globally exact + counter. +- Rollback changes route policy back to the existing Durable Object without removing bindings. + +## Phase 3: one navigation bootstrap request + +### Goal + +Replace the initial sidebar's project/thread N+1 fan-out with one authenticated request and a fixed +number of set-based database statements. + +### API contract + +Add `GET /v1/bootstrap/navigation?activeThreadId=` with a strict response schema in +`packages/types/src/api.ts`: + +- `recentThreads`: the latest 20 visible threads across the tenant; +- `projects`: the latest six project summaries, each with its latest visible thread or `null`; +- `activeThread`: the requested active thread or `null`; +- `activeProject`: the active thread's project or `null`, included even when it is outside the six + recent projects. + +Do not include greeting/weather data in this endpoint; external data must not delay critical +navigation. + +### Implementation + +1. Add `packages/db/src/navigation-bootstrap.ts` and export it from + `packages/db/src/index.ts`. Load the response in one `withUserDb` transaction using bounded, + set-based queries. Use a window/lateral query for the latest thread per selected project; never + loop over project IDs. Parallelize truly independent non-database work after authentication, but + do not `Promise.all` statements on the same transaction-pinned `max: 1` connection; collapse that + work into set-based SQL instead. +2. Reuse existing project/thread mappers and branded IDs. Add an index only if `EXPLAIN` on + production-shaped data shows the current recent-thread/project indexes are insufficient; any DB + change follows `generate -> review -> dry-run -> approved apply`. +3. Add `apps/gateway-worker/src/bootstrap-http-routes.ts` and + `apps/gateway-worker/src/bootstrap-routes.ts`, register them from + `apps/gateway-worker/src/index.ts`, and apply one principal resolution plus one cheap-read rate + decision. +4. Add `apps/web/src/lib/api/navigation-bootstrap.ts` with response validation and add + `sidebarKeys.navigation(activeThreadId)` in `apps/web/src/lib/api/query-keys.ts`. +5. Refactor `apps/web/src/components/shell/sidebar-data.ts` and + `apps/web/src/components/shell/sidebar-controller.ts` to use one query. Preserve the existing UI + return shapes so view components do not absorb transport concerns. +6. Seed compatible project/thread detail caches from the bootstrap response where it avoids later + duplicate reads; do not make those caches the source of authorization truth. +7. Update create/rename/delete/move mutations in the sidebar components and + `invalidateChatLists()` to invalidate the navigation bootstrap key as well as any detail keys. +8. Keep existing list/detail endpoints operational for project pickers, settings, pagination, + deep links, and rollback. +9. Keep `trycheatcode.com`/the Vercel apex DNS-only. Add `` and DNS-prefetch + for `https://gateway.trycheatcode.com` in `apps/web/src/app/layout.tsx`; do not proxy `/v1` + through Vercel or orange-cloud the apex. Retain the Gateway's one-day CORS preflight cache. + +### Edge cases and acceptance + +- Zero projects, project-less chats, deleted records, an unknown active thread, an active project + outside the first six, running-chat indicators, slow database, and signed-out transitions. +- Query count is fixed regardless of project count; response size is bounded and Zod-validated. +- The sidebar renders from one critical API request; home greeting may remain a separate noncritical + request. +- The complete initial signed-in surface uses no more than three authenticated requests, and the + preconnect is established before the first Gateway fetch. +- Rollback switches the web query hook to the old endpoints; the additive endpoint can remain. + +## Phase 4: true durable model streaming + +### Goal + +Replace the buffered `.generate()` model turn with provider token streaming while retaining +Cloudflare Workflow retries, Durable Object ordering, reconnection, tool checkpointing, fallback, +and cancellation correctness. + +### Durable turn protocol + +Use a stable turn ID derived from the current Workflow identity and model step index. Store turn +lease/status, first-visible state, logical model, and chunked final result in namespaced +`run_state` keys in the existing AgentRun SQLite schema. Chunk final serialized state below the +one-megabyte value bound and store a manifest/hash last. This avoids an incompatible table change +for existing Durable Objects. + +AgentRun exposes narrow Workflow-authenticated operations: + +- `beginModelTurn`: return `new`, `leased`, `partial`, or a validated completed result; +- `appendModelTurnChunks`: atomically deduplicate deterministic event keys, append UI chunks, and + renew the lease; +- `completeModelTurn`: validate the accumulated result, persist chunked result plus manifest/hash, + then mark complete; +- `failModelTurn`: mark whether failure occurred before or after visible output; +- `readModelTurn`: replay a completed result to a retried Workflow step without a provider call. + +### Implementation + +1. In `packages/agent-core/src/mastra/durable-agent-step.ts`, add a streaming model-step API using + the Mastra/AI SDK stream interface. It must expose text deltas as they arrive and return the same + validated final `finishReason`, response messages, text, and tool calls currently returned by + `generateGeneralAgentStep()`. +2. Retain the buffered function temporarily behind the streaming rollout flag for rollback. Keep + DeepSeek provider options and active-tool selection identical. +3. Add `apps/agent-worker/src/durable-objects/agent-run-model-turn.ts` for the turn state machine, + lease checks, result chunking/hash verification, and deterministic event keys. Reuse + `appendAgentRunMessagePartOnce()` and the existing message-part byte bounds. +4. Add the narrow RPC methods to `apps/agent-worker/src/durable-objects/agent-run.ts`. Every method + must validate the current Workflow callback/input hash, deletion tombstone, cancellation state, + and lease owner before mutating storage. +5. Refactor `generateWithCredential()` and `generateWorkflowModelStep()` in + `apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts`: + - acquire the turn before contacting the provider; + - return an already-completed turn on Workflow retry; + - emit `text-start` and the first non-empty delta immediately; + - coalesce subsequent deltas for at most 25 ms or 512 UTF-8 bytes; + - accumulate the exact final model result; + - mark the turn complete before returning from the Workflow step. +6. Refactor `publishModelStep()` in + `apps/agent-worker/src/durable-objects/agent-run-workflow.ts` so it persists selected model and + advances Workflow state without publishing model text a second time. It may publish metadata and + close an open text part only through deterministic once-only keys. +7. Fallback behavior: + - primary failure before visible output may release/fail the primary attempt and run the existing + OpenAI fallback under the same logical turn; + - failure after visible output is terminal for that turn and must not start fallback; + - `data-model-fallback` is emitted exactly once before fallback text. +8. Cancellation aborts the provider stream, prevents later chunks, closes any open UI part with the + existing error protocol, and leaves reconnectable partial output. A stale Workflow cannot append + after cancel/delete/replacement. +9. Extend `apps/agent-worker/src/durable-objects/agent-run-performance.ts` with provider first text, + first durable append, coalescing delay, completed-turn replay, and post-visible failure metrics. +10. Keep stream responses `private, no-store` and add `no-transform` in + `apps/agent-worker/src/streaming/ui-message-stream.ts`. Run an A/B canary with compression + disabled for only the stream route to detect compressor/proxy buffering; keep the winning + variant based on first-model-text p75/p95 and bandwidth, not assumption. +11. After run identity, pending Workflow intent, and recovery state are durably committed, add an + admission fast path that returns create-run headers immediately and emits a status/heartbeat + event. Move Workflow initiation or Gateway idempotency completion out of the response critical + path only after termination-injection tests prove the committed recovery protocol can resume + them exactly once. Until that proof exists, keep them awaited. +12. Update Agent Worker and agent-core READMEs with the retry/visibility and admission-recovery + invariants. + +### Acceptance matrix + +- Text-only completion, tool-only turn, mixed text/tool turn, multiple tool calls, provider length + continuation, DeepSeek large output, primary fallback, explicit-model no-fallback, cancellation + before/after first text, client disconnect/reconnect, DO restart, Workflow retry before first text, + Workflow retry after completion, and injected failure after visible text. +- Transcript sequence is monotonic and exactly once. Final persisted conversation equals the model + result and contains no duplicated prefix. +- Existing stream cursors reconnect mid-turn without restarting generation. +- Create-run headers target p75 below 500 ms, first status p75 below 750 ms, and first actual model + token p75 below 2.5 s segmented by provider. Phase 0 may refine these only with recorded evidence. +- Feature flag permits immediate fallback to buffered generation for new turns; already-started + streaming turns retain their stored protocol version. + +## Phase 5: Agent Worker startup and bundle reduction + +### Goal + +Reduce cold-start exposure without moving architectural boundaries speculatively. + +### Implementation + +1. Use `scripts/worker-performance-report.ts` and `wrangler check startup` to capture raw/gzip + bundle size, startup time, and the + largest/transitively expensive modules for every Worker. Store budgets in a small checked-in + config, not generated build output. +2. Profile module initialization beginning at `apps/agent-worker/src/index.ts`, + `packages/agent-core/src/mastra/index.ts`, and + `packages/agent-core/src/mastra/agents/general.ts`. +3. Remove or defer only measured eager work: + - module-level object construction not needed for admission/stream attach; + - provider SDK initialization before a credential/model is selected; + - unrelated workflow/agent registration on paths that do not execute them; + - duplicate dependency copies identified in the Wrangler metafile/lockfile. +4. Preserve static Worker-compatible imports where dynamic loading would break bundling or runtime + compatibility. Verify every change against all four model providers and the research/tool + registry. +5. Add a CI performance report and fail only on agreed regression budgets. Do not make local startup + variance a flaky single-sample gate; use repeated median measurements and a size hard limit. +6. If measured evidence still cannot reach the success gate, write a separate architecture decision + for lightweight latency-sensitive Workers and heavy execution. Candidate extractions are signed + artifact delivery, status/stream transport, preview coordination, and finally model execution. + Connect them through fetch-based Service Bindings so latency-sensitive calls avoid public + network/auth overhead and remain eligible for placement. The decision must validate + cross-Worker Workflow/DO/service-binding support, failure ownership, deployment order, and cost + before implementation. + +### Acceptance + +- Agent Worker reaches a 25-40% startup CPU reduction (or falls below 200 ms) without increasing run + errors or removing capabilities, and production cold-path p95 confirms the local result. The + current raw 13.8 MB/2.47 MB gzip bundle is the initial size reference, not a permanent budget. +- Gateway, webhooks, and preview bundles do not regress. +- Each lazy/deferred boundary has a cold-path browser/run verification, not only a build check. +- Rollback is per optimization. A Worker split is not part of this phase unless separately approved. + +## Phase 6: Hyperdrive reconciliation + +### Goal + +Verify the current direct Supabase port-5432 production targets, align repository setup/documentation +with Cloudflare's direct-endpoint recommendation, and keep tenant query caching disabled. + +### Implementation + +1. With the Phase 0 timings, separate handle creation, connection acquisition, signed-context setup, + first query, transaction body, commit, and close in `packages/db/src/client.ts`. +2. Inventory the three production Hyperdrive configurations referenced by: + - `apps/gateway-worker/wrangler.jsonc` (`app_gateway`); + - `apps/agent-worker/wrangler.jsonc` (`app_agent`); + - `apps/webhooks-worker/wrangler.jsonc` (`app_webhooks`). + Record only config IDs, role names, region, endpoint class, cache state, and connection limits; + never persist connection strings or secrets. +3. Confirm Supabase database region, direct endpoint, TLS requirements, exact role identity, and + available connection headroom. Treat any unexpected session-pooler target as drift. +4. Create fresh canary Hyperdrive configs against the direct endpoint with query caching disabled + and the same least-privilege role. Do not mutate all production bindings in place. A + session-pooler candidate is a contingency only if direct-path latency, saturation, or correctness + fails the release gate. +5. Validate the direct config under signed-in bootstrap, run persistence, webhook processing, + concurrency, connection churn, and failure recovery. Include RLS identity assertions and + transaction-local context tests. +6. Tune origin connection limits only after confirming database headroom; retain the request-scoped + driver pool `max: 1` unless profiling proves a safe need for intra-request concurrency. +7. Promote verified direct config IDs Worker by Worker, starting with Gateway, then Agent, then + webhooks. Update repository setup so it no longer requires the session pooler. Align Wrangler + config, operational setup scripts, app READMEs, and `packages/db/README.md` with the selected + production truth. +8. Inventory public, stale-tolerant catalog/reference reads. Only if such reads are material, create + a separate cache-enabled Hyperdrive configuration and dedicated public Worker entrypoint with no + tenant/RLS/auth/billing queries. Document staleness because cached SELECTs are not invalidated by + application writes. +9. Keep Hyperdrive query caching off for all current tenant/auth/billing/RLS traffic. + +### Acceptance and rollback + +- All three runtime roles remain least-privilege and exact; no `service_role` or migration login is + used. +- Signed context and forced RLS tests pass under load and connection reuse. +- Verified direct config meets the latency/error gate for at least 24 hours before full promotion. +- Rollback rebinds the prior Hyperdrive config ID; no database migration is involved. + +## Phase 7: Smart Placement canaries + +### Goal + +Determine whether placed, database-heavy fetch entrypoints help without moving the user-facing +Gateway away from users or relying on placement for unsupported named/RPC entrypoints. + +### Implementation + +1. Keep the public Gateway user-near and unplaced. Add placement only to staging/canary fetch + entrypoints in the relevant `wrangler.jsonc` and deploy workflow. Test database-heavy fetch and + Agent execution surfaces separately; do not enable all Workers at once. +2. Record user colo, actual execution location/placement status, database phase time, Durable Object + time, service-binding time, provider/sandbox time, total time, release SHA, and variant. +3. Run representative traffic from India, North America, and Europe for: + - signed-in navigation bootstrap; + - create-run admission and stream connection; + - a text-only model turn; + - a sandbox/tool-heavy run. +4. For a database-heavy Gateway operation such as navigation bootstrap, canary a placed Worker + reached through a fetch-based Service Binding while the public Gateway remains user-near. Do not + use a named/RPC entrypoint for placement-critical calls. +5. Evaluate Agent fetch entrypoints independently because its dominant dependencies include model providers, Daytona, + Durable Objects, R2, and Postgres rather than one origin. +6. Promote only the specific Worker whose p75 improves at least 10%, p95 regresses no more than 5%, + and error/cold-start rates remain stable across all tested regions. +7. For newly created named Durable Objects, pass a best-effort location hint derived from the + initiating user region or Daytona target when the Cloudflare API permits it. Hints are advisory, + must not become an authorization input, and must not alter deterministic object names. +8. Do not attempt to relocate existing Durable Objects. Record hinted versus actual location and + accept that existing objects remain sticky. + +### Rollback + +Disable placement on the affected Worker version and redeploy. No DNS, storage, or database change +is coupled to this phase. + +## Phase 8: artifacts, previews, and polling removal + +### Goal + +Make large generated outputs resumable and conditionally cacheable, reduce redundant artifact work, +and replace fixed active-run/preview polling where existing Durable Object streams can carry state. + +### Implementation + +1. Add a focused HTTP helper such as + `apps/agent-worker/src/r2-download-response-support.ts` that: + - parses one RFC-compatible `bytes` range (closed, open-ended, or suffix); + - rejects malformed or multi-range requests; + - maps the validated range to `R2Bucket.get(key, { range })`; + - returns `206`, `Content-Range`, `Content-Length`, and `Accept-Ranges: bytes`; + - returns `416` with `Content-Range: bytes */` for unsatisfiable ranges; + - compares `If-None-Match` against R2 `httpEtag` and returns `304` only after signed capability + and tenant ownership validation. +2. Refactor `downloadOutput()` in `apps/agent-worker/src/agent-api-system-routes.ts` to use the + helper while preserving signature verification, `findDownloadableOutput()`, content type, + sanitized content disposition, `nosniff`, and `Referrer-Policy: no-referrer`. +3. Set `ETag`, `Accept-Ranges`, and exact length on complete responses. For successful GET/206, + retain `Cache-Control: private, no-store`. Honor `If-None-Match` only after signature and current + DB ownership checks, so a client-supplied validator can receive `304` without weakening + deletion/revocation. Do not add `immutable` or a positive private-cache TTL in this initiative. +4. Forward stored R2 HTTP metadata only through an allowlist (`Content-Type`, language/encoding when + valid, cache validators, and length); security headers and sanitized content disposition remain + application-owned. +5. Remove the mint route's extra R2 `head()` only after verifying that `generated_outputs` is + committed strictly after a successful R2 upload. The download `get()` remains the object + existence check, and the download route must recheck DB ownership because outputs can be deleted + during a capability's one-hour lifetime. +6. Consider HEAD only if the Gateway-to-Agent route manifest can express it without duplicating an + ambiguous route. HEAD is not required for the first release; browser range and conditional GET + deliver the material gain. +7. Keep artifact delivery in the Agent Worker for the first range/ETag release. If Phase 5 profiling + shows this heavy bundle materially affects artifact cold p95, move the signed delivery route and + R2 binding into the approved lightweight artifact Worker through a fetch Service Binding. +8. Replace the fixed two-second active-run polling in + `apps/web/src/components/projects/projects-shell.tsx` with the existing AgentRun stream's terminal + status/event followed by one detail invalidation. Use exponential backoff plus visibility pause + only as a recovery fallback when the stream is unavailable. +9. Replace fixed preview/status polling in + `apps/web/src/components/preview/use-ensure-preview-live.ts` and polling in + `apps/web/src/components/preview/sandbox-ide-tab.tsx` with ProjectSandbox/AgentRun lifecycle + events where those state owners already know the transition. Retain bounded exponential backoff, + focus/visibility gating, and manual retry as the degraded path. +10. Keep preview console cursor polling only while the console surface is visible until a bounded + event transport exists; immediately switch it from fixed cadence to exponential idle/error + backoff in `apps/web/src/components/preview/use-preview-console.ts`. +11. Cache only immutable code-server assets. Never cache arbitrary user previews, capability URLs, + preview HTML, terminal/console data, or mutable sandbox responses. +12. Document that capability URLs remain sensitive and must not enter logs, analytics, or referrers. + +### Acceptance + +- Full object, first/last/open/suffix ranges, zero-byte object, malformed range, unsatisfiable range, + matching/nonmatching ETag, expired signature, wrong user, missing DB row, missing R2 object, and + UTF-8/special-character filename cases. +- 304 and 206 are never returned before authorization and ownership checks. +- Memory remains streaming/bounded; the Worker never buffers the entire object. +- Active-run completion updates through the run stream without a two-second poll; fallback polling + backs off, pauses while hidden, and converges after reconnect. +- Preview lifecycle changes are event-driven where the state owner can emit them; degraded polling + does not create a constant background request loop. +- Rollback restores whole-object GET; the `no-store` security contract, object data, and signed URLs + remain valid throughout rollout. + +### Conditional shared delivery path + +Only if telemetry shows substantial repeat reads of immutable public thumbnails/artifacts, design an +R2 custom-domain path protected by a scoped HMAC/WAF rule and Tiered Cache. Private user outputs stay +off shared cache by default. This is not a prerequisite for range/ETag delivery. + +## Phase 9: selective caching and Cloudflare zone controls + +### Goal + +Make caching/security protocol intent explicit and drift-controlled without applying shared caching +to authenticated or user-generated traffic. + +### Cache matrix + +| Surface | Policy | +|---|---| +| Public catalogs/reference metadata | Dedicated public entrypoint; bounded stale-tolerant cache when telemetry justifies it. | +| Release metadata and public skill metadata | Public cache with explicit TTL, validators, and purge/version strategy. | +| Immutable public assets | Long-lived immutable cache; Tiered Cache only when repeat-read metrics justify it. | +| Authenticated APIs, Clerk, billing, permissions | Never shared-cache. | +| Webhooks, agent streams, run status, user outputs | Never shared-cache; private stream/output rules from earlier phases apply. | +| User previews, preview capabilities, terminal/console data | Never shared-cache; only immutable code-server assets are eligible. | + +### Implementation + +1. Inventory every public Gateway/Worker route and classify it in a checked-in cache-policy manifest. + A new route must choose a policy explicitly; absence defaults to bypass. +2. Evaluate Cloudflare Workers Caching only on dedicated public entrypoints where bypassing Worker + execution, request collapsing, and tiered delivery materially help. Do not enable it on the + authenticated Gateway. +3. Define cache rules and bypasses for the matrix. Auth/cookie/capability-bearing requests fail + closed to bypass even if a route is otherwise public. +4. Put HTTP/3, TLS 1.3, Compression Rules, cache rules/bypasses, and any approved R2 Tiered Cache + configuration into Terraform or equivalent declarative IaC. Because the repository has no + current zone-IaC source of truth, first add an architecture decision selecting the tool, state + ownership, secret handling, CI plan/apply permissions, and emergency rollback process. +5. Import the existing zone state before changing it. The Gateway already demonstrates HTTP/3 and + zstd, so protocol settings are verification/drift-control tasks rather than expected latency + wins. +6. Add CI drift checks and a reviewed apply workflow. Production apply credentials remain outside + the repository and changes must produce a human-readable plan artifact. +7. Add synthetic/public-route checks for cache status, TTL, validator behavior, auth bypass, + compression, HTTP/3, and TLS policy. Never use cached success as proof that the Worker route + itself remains healthy. + +### Acceptance and rollback + +- Every public route has an explicit cache/bypass policy and private surfaces always bypass. +- IaC plan is clean after apply; out-of-band zone drift is visible in CI. +- Existing HTTP/3, TLS 1.3, and zstd behavior remains available where compatible. +- Rollback applies the previous reviewed IaC state; cache purge does not require application data + deletion. + +## Cross-phase deployment policy + +1. Land Phase 0 first with no user-visible behavior change and tag every metric with release SHA and + experiment variant. +2. Before switching users, run contract/parity checks against production-shaped data: + - compare bootstrap output with the existing sidebar endpoints, including empty/deleted/active + states; + - run the native rate limiter in decision-only shadow mode while the Durable Object remains + authoritative; + - compare Hyperdrive/placement candidates with identical RLS identity assertions; + - validate streaming on internal runs rather than dual-calling nondeterministic model providers. +3. Ship each behavior change disabled or additive, then enable for staff/internal accounts, 1%, 5%, + 25%, 50%, and 100% only after the phase gate holds for a representative window. +4. Never overlap Hyperdrive and Smart Placement experiments. +5. Monitor technical and UX guardrails together: auth success, initial render/sidebar correctness, + run creation, first status/text, successful completion, cancellation, reconnect, preview ready, + download success, 429s, and support/error signals. +6. Stop promotion and activate the prior path on elevated 4xx/5xx, auth/entitlement differences, + RLS assertion failures, stale/missing UI state, stream duplication, canceled-run leakage, + connection saturation, completion-rate decline, or p95 regression beyond the gate. +7. Keep the previous Worker version/config ID and client code path available for immediate rollback. + Prefer an automated rollback alarm where the metric is reliable; otherwise use a documented + one-command kill switch and staffed canary observation. +8. Remove a flag only after full rollout has been stable for at least seven days and the rollback + release remains buildable. + +## Required verification for every implementation PR + +Run the repository gate chain: + +```bash +pnpm lint +pnpm typecheck +pnpm turbo build --force +pnpm deadcode +pnpm architecture:check +pnpm turbo skills:build +``` + +Also run relevant package checks and the new Worker performance report. For user-visible or +integration behavior, use the real application with: + +```bash +agent-browser --auto-connect --session cheatcode-debug +``` + +PR verification notes must list commands, production/canary variants, browser flows, screenshots or +network evidence, Workers log/metric evidence, and every omitted check with its reason. This +repository intentionally does not add a separate unit/E2E harness for these changes. + +## Documentation and operational updates + +Update the relevant source of truth in the same PR: + +- `apps/gateway-worker/README.md` for auth, rate limits, bootstrap, headers, and bindings; +- `apps/agent-worker/README.md` for model-turn durability, placement, and output delivery; +- `packages/agent-core/README.md` for streamed model-step behavior; +- `packages/db/README.md` for the selected Hyperdrive endpoint and cache policy; +- `packages/observability/README.md` for metrics and redaction; +- `apps/web/README.md` for the DNS-only frontend, Gateway preconnect, and event/backoff behavior; +- app `wrangler.jsonc` files and deployment workflows for bindings/config IDs/placement; +- the selected zone-IaC/architecture document and cache-policy manifest for protocol/cache controls; +- `packages/types` and package public exports for every new wire contract. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Workflow retry duplicates streamed output | Stable turn IDs, deterministic event keys, AgentRun lease, completed-result replay, and no post-visible retry. | +| Token-sized writes overload the Durable Object | Immediate first token, then 25 ms/512-byte coalescing with existing byte bounds and metrics. | +| Native rate limit is not globally exact | Use it only for fail-open cheap reads; retain Durable Objects for strict policies. | +| Bootstrap response becomes another oversized aggregate | Fixed 20 chats/six projects/one latest thread each; strict response schema and response-size telemetry. | +| Hyperdrive endpoint change breaks RLS/session behavior | Same least-privilege roles, cache disabled, canary config IDs, signed-context assertions under connection reuse. | +| Smart Placement helps DB but hurts users | Keep the public Gateway user-near; measure placed fetch entrypoints end-to-end in multiple regions and promote per Worker only. | +| Lazy imports remove provider/tool capabilities | Metafile evidence plus real runs across every provider and tool/research path. | +| Conditional artifact response bypasses revocation | Keep private outputs `no-store`; check signature and current DB ownership before every 200/206/304. | +| Event loss leaves stale preview/run state | Stream sequence/reconnect recovery plus one invalidation; bounded visibility-aware exponential polling remains the degraded path. | +| A broad cache rule exposes tenant data | Checked-in deny-by-default matrix, auth/cookie/capability bypasses, IaC review, and synthetic negative checks. | +| A faster backend changes visible behavior | Additive APIs, shadow parity where deterministic, internal-first canaries, UX guardrail metrics, feature kill switches, and retained old paths. | + +## Remaining data-driven decisions + +These are experiment outcomes, not blockers to starting implementation: + +1. Whether the verified direct Supabase configs need different origin connection limits for + Gateway, Agent, and webhooks; the session pooler remains a contingency, not the expected target. +2. Whether placed database-heavy fetch and Agent execution entrypoints beat unplaced variants; the + public Gateway remains user-near. +3. Which measured eager imports should be deferred and whether a lightweight Worker split deserves a separate + architecture proposal. +4. The absolute p75/p95 SLO numbers frozen after Phase 0's 24-hour baseline. +5. Whether material public/stale-tolerant reads justify a separate cache-enabled Hyperdrive config, + Workers Caching, or an R2 custom-domain/Tiered Cache path. diff --git a/infra/cloudflare/.terraform.lock.hcl b/infra/cloudflare/.terraform.lock.hcl new file mode 100644 index 00000000..6c70425a --- /dev/null +++ b/infra/cloudflare/.terraform.lock.hcl @@ -0,0 +1,19 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "5.24.0" + constraints = "~> 5.0" + hashes = [ + "h1:F3V4hF42Y/Usl9OhzNFQHwUL8oNXTzbY5x6dQaDaASc=", + "zh:2390fc5df95addfd47d3f638964a1f9f6192a8c84ad3b1eab554ef88e0ac4091", + "zh:2b09c0afbebeb3139a3094e3debda1ba5ff3d73b6eca536549bdc903284b6798", + "zh:3241ce471f20745b1dc93baea73a716e1db4637ff41acc134114b7a8c9684714", + "zh:3513243e5582836054076a21abeadd8142a8421df6ec1ead53d96dc37a9e6326", + "zh:99b33ccb8a1c10a08e8903a2848eb2664c889dc79801a1e61e0373dd41c6c0f9", + "zh:99c7b510b100a605b0c80e0c3665d99c2381b0834f52ad6ca767e161c2ffa416", + "zh:c523d747a2d8457bc2d2cc00967c419ef38a5ebebd12d5091cf18322b7201f04", + "zh:f520e37f4d875b6fee95cffb74cf5fe9efc3cd54fe6bb4b815da5cee87e517e8", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + ] +} diff --git a/infra/cloudflare/README.md b/infra/cloudflare/README.md new file mode 100644 index 00000000..736e48b5 --- /dev/null +++ b/infra/cloudflare/README.md @@ -0,0 +1,50 @@ +# Cloudflare zone controls + +Terraform owns the performance-sensitive zone invariants that cannot live in a +Worker's `wrangler.jsonc`: HTTP/3, TLS 1.3, apex proxy state, gateway cache +bypass, and response compression order. + +The frontend apex remains on Vercel and is intentionally `proxied = false`. +Supply the current record type and value shown by Vercel; they are variables +because Vercel may change its prescribed target. Never infer or copy an old +target. The gateway remains the only orange-clouded application hostname. + +## Cache matrix + +| Surface | Shared cache policy | Reason | +|---|---|---| +| Authenticated `/v1/*` APIs | Never | Tenant identity, permissions, billing, and RLS | +| Clerk, billing, webhooks, telemetry writes | Never | Credentials or state mutation | +| Agent create/reconnect streams | Never; no transform | Private live state and latency-sensitive chunking | +| Signed R2 output downloads | Never by default | User-owned content; capability URLs are not public assets | +| Daytona previews and console | Never | Arbitrary user code and private live state | +| Public catalog/release/skill metadata | Dedicated public hostname/entrypoint only | No such stable unauthenticated surface exists yet | +| Immutable public thumbnails/assets | Conditional future rule | Enable only after repeat-read telemetry justifies it | + +The gateway Worker also supplies `private, no-store` by default for every +non-OPTIONS `/v1/*` response that does not already declare a policy. The zone +rule is defense in depth. Do not add a positive cache rule to +`gateway.trycheatcode.com`; create a dedicated public Worker entrypoint first. + +R2 Tiered Cache and a custom download domain are intentionally absent: current +outputs are private and telemetry has not established material repeat reads. + +## Safe adoption + +Existing zone resources must be imported before the first plan. In particular, +import the apex DNS record and any existing phase entrypoint rulesets instead +of allowing Terraform to create competing records or overwrite dashboard rules. +Use a remote, encrypted Terraform backend in the deployment environment; local +state is git-ignored. + +```bash +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform fmt -check +terraform validate +terraform plan +``` + +`terraform apply` is an explicit production operation and is not part of normal +code verification. The API token needs only DNS, Zone Settings, Cache Rules, +and Compression Rules edit permissions for this zone. diff --git a/infra/cloudflare/analytics/README.md b/infra/cloudflare/analytics/README.md new file mode 100644 index 00000000..778cd525 --- /dev/null +++ b/infra/cloudflare/analytics/README.md @@ -0,0 +1,39 @@ +# Performance dashboards + +The `cc_performance_metrics` dataset is the shared request/run/browser latency +source. Configure saved Analytics Engine SQL queries for 24-hour and 7-day +windows, then graph p50/p75/p95 by route, Worker, release, colo, placement, +reconnect state, provider, and logical model. + +Analytics Engine stores missing doubles as zero to preserve the locked column +order. Every percentile query must therefore filter the selected double above +zero. Use `QUANTILEEXACTWEIGHTED(value, percentile, _sample_interval)` so read +or write sampling remains statistically represented. + +Column aliases: + +| Dimension | Column | +|---|---| +| route | `index1` | +| Worker | `blob1` | +| release/version | `blob3` | +| metric name | `blob5` | +| colo | `blob6` | +| placement | `blob7` | +| reconnect state | `blob8` | +| provider | `blob9` | +| logical model | `blob10` | +| total request/browser milestone | `double2` | +| DB query | `double3` | +| DB connection/context | `double11` / `double12` | +| Service Binding / Durable Object | `double13` / `double14` | +| Workflow acceptance / response headers | `double15` / `double16` | +| first status / first model text | `double17` / `double18` | +| final token / run completion | `double19` / `double20` | + +The saved query templates cover request totals, browser milestones, streaming +targets, and the Smart Placement canary. Correlate regressions with Workers +automatic traces, Hyperdrive connection/query metrics, Durable Object request +metrics, and the release/version dimension. Alert targets are create-run +response headers p75 < 500 ms, first status p75 < 750 ms, and first model text +p75 < 2.5 s segmented by provider. diff --git a/infra/cloudflare/analytics/browser-milestones.sql b/infra/cloudflare/analytics/browser-milestones.sql new file mode 100644 index 00000000..b1ef0b02 --- /dev/null +++ b/infra/cloudflare/analytics/browser-milestones.sql @@ -0,0 +1,14 @@ +SELECT + blob5 AS milestone, + index1 AS route, + QUANTILEEXACTWEIGHTED(double2, 0.50, _sample_interval) AS p50_ms, + QUANTILEEXACTWEIGHTED(double2, 0.75, _sample_interval) AS p75_ms, + QUANTILEEXACTWEIGHTED(double2, 0.95, _sample_interval) AS p95_ms, + SUM(_sample_interval) AS samples +FROM cc_performance_metrics +WHERE timestamp > NOW() - INTERVAL '1' DAY + AND blob1 = 'web' + AND blob5 IN ('run_response_headers', 'run_first_status', 'run_first_model_text', 'run_stream_finished') + AND double2 > 0 +GROUP BY milestone, route +ORDER BY milestone, p95_ms DESC diff --git a/infra/cloudflare/analytics/placement-canary.sql b/infra/cloudflare/analytics/placement-canary.sql new file mode 100644 index 00000000..c66e5bb1 --- /dev/null +++ b/infra/cloudflare/analytics/placement-canary.sql @@ -0,0 +1,14 @@ +SELECT + blob7 AS placement, + index1 AS route, + QUANTILEEXACTWEIGHTED(double2, 0.50, _sample_interval) AS p50_ms, + QUANTILEEXACTWEIGHTED(double2, 0.75, _sample_interval) AS p75_ms, + QUANTILEEXACTWEIGHTED(double2, 0.95, _sample_interval) AS p95_ms, + SUM(_sample_interval) AS requests +FROM cc_performance_metrics +WHERE timestamp > NOW() - INTERVAL '1' DAY + AND blob1 = 'artifact' + AND blob7 != '' + AND double2 > 0 +GROUP BY placement, route +ORDER BY route, placement diff --git a/infra/cloudflare/analytics/request-percentiles.sql b/infra/cloudflare/analytics/request-percentiles.sql new file mode 100644 index 00000000..d3e63f6e --- /dev/null +++ b/infra/cloudflare/analytics/request-percentiles.sql @@ -0,0 +1,13 @@ +SELECT + index1 AS route, + blob1 AS worker, + blob3 AS release, + QUANTILEEXACTWEIGHTED(double2, 0.50, _sample_interval) AS p50_ms, + QUANTILEEXACTWEIGHTED(double2, 0.75, _sample_interval) AS p75_ms, + QUANTILEEXACTWEIGHTED(double2, 0.95, _sample_interval) AS p95_ms, + SUM(_sample_interval) AS requests +FROM cc_performance_metrics +WHERE timestamp > NOW() - INTERVAL '1' DAY + AND double2 > 0 +GROUP BY route, worker, release +ORDER BY p95_ms DESC diff --git a/infra/cloudflare/analytics/run-streaming-percentiles.sql b/infra/cloudflare/analytics/run-streaming-percentiles.sql new file mode 100644 index 00000000..b47a2265 --- /dev/null +++ b/infra/cloudflare/analytics/run-streaming-percentiles.sql @@ -0,0 +1,13 @@ +SELECT + blob9 AS provider, + blob10 AS logical_model, + QUANTILEEXACTWEIGHTED(double18, 0.50, _sample_interval) AS p50_ms, + QUANTILEEXACTWEIGHTED(double18, 0.75, _sample_interval) AS p75_ms, + QUANTILEEXACTWEIGHTED(double18, 0.95, _sample_interval) AS p95_ms, + SUM(_sample_interval) AS runs +FROM cc_performance_metrics +WHERE timestamp > NOW() - INTERVAL '1' DAY + AND index1 = '/internal/runs/start' + AND double18 > 0 +GROUP BY provider, logical_model +ORDER BY p95_ms DESC diff --git a/infra/cloudflare/main.tf b/infra/cloudflare/main.tf new file mode 100644 index 00000000..0119a35a --- /dev/null +++ b/infra/cloudflare/main.tf @@ -0,0 +1,88 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 5.0" + } + } +} + +provider "cloudflare" {} + +resource "cloudflare_dns_record" "vercel_apex" { + zone_id = var.zone_id + name = var.zone_name + content = var.vercel_apex_content + type = var.vercel_apex_record_type + ttl = 300 + proxied = false + comment = "Vercel frontend apex; intentionally DNS-only" +} + +resource "cloudflare_zone_setting" "tls_1_3" { + zone_id = var.zone_id + setting_id = "tls_1_3" + value = "on" +} + +resource "cloudflare_zone_setting" "http3" { + zone_id = var.zone_id + setting_id = "http3" + value = "on" +} + +resource "cloudflare_ruleset" "gateway_cache" { + zone_id = var.zone_id + name = "Cheatcode gateway cache policy" + description = "Never share-cache authenticated, signed, streamed, or user-scoped gateway traffic" + kind = "zone" + phase = "http_request_cache_settings" + + rules = [ + { + ref = "cheatcode_gateway_cache_bypass" + description = "Bypass cache for the complete authenticated gateway" + expression = "http.host eq \"${var.gateway_hostname}\"" + action = "set_cache_settings" + action_parameters = { + cache = false + } + } + ] +} + +resource "cloudflare_ruleset" "gateway_compression" { + zone_id = var.zone_id + name = "Cheatcode gateway compression policy" + description = "Prefer modern compression for bounded responses and disable transforms on run streams" + kind = "zone" + phase = "http_response_compression" + + rules = [ + { + ref = "cheatcode_gateway_modern_compression" + description = "Prefer Zstandard with Brotli/Gzip/automatic fallback" + expression = "http.host eq \"${var.gateway_hostname}\"" + action = "compress_response" + action_parameters = { + algorithms = [ + { name = "zstd" }, + { name = "brotli" }, + { name = "gzip" }, + { name = "auto" } + ] + } + }, + { + ref = "cheatcode_agent_stream_no_compression" + description = "Prevent compressor buffering on create and reconnect run streams" + expression = "(http.host eq \"${var.gateway_hostname}\" and starts_with(http.request.uri.path, \"/v1/threads/\") and ((http.request.method eq \"POST\" and ends_with(http.request.uri.path, \"/runs\")) or (http.request.method eq \"GET\" and ends_with(http.request.uri.path, \"/runs/stream\"))))" + action = "compress_response" + action_parameters = { + algorithms = [{ name = "none" }] + } + } + ] +} diff --git a/infra/cloudflare/terraform.tfvars.example b/infra/cloudflare/terraform.tfvars.example new file mode 100644 index 00000000..a605ac90 --- /dev/null +++ b/infra/cloudflare/terraform.tfvars.example @@ -0,0 +1,3 @@ +zone_id = "" +vercel_apex_record_type = "A" +vercel_apex_content = "" diff --git a/infra/cloudflare/variables.tf b/infra/cloudflare/variables.tf new file mode 100644 index 00000000..f308891e --- /dev/null +++ b/infra/cloudflare/variables.tf @@ -0,0 +1,31 @@ +variable "zone_id" { + description = "Cloudflare zone identifier for trycheatcode.com" + type = string +} + +variable "zone_name" { + description = "Canonical frontend apex" + type = string + default = "trycheatcode.com" +} + +variable "gateway_hostname" { + description = "Orange-clouded Worker gateway hostname" + type = string + default = "gateway.trycheatcode.com" +} + +variable "vercel_apex_record_type" { + description = "Current Vercel-prescribed apex record type" + type = string + + validation { + condition = contains(["A", "AAAA", "CNAME"], var.vercel_apex_record_type) + error_message = "vercel_apex_record_type must be A, AAAA, or CNAME." + } +} + +variable "vercel_apex_content" { + description = "Current Vercel-prescribed apex record content; verify in Vercel before every change" + type = string +} diff --git a/infra/containers/dev/Dockerfile b/infra/containers/dev/Dockerfile index b00a3c94..8077d372 100644 --- a/infra/containers/dev/Dockerfile +++ b/infra/containers/dev/Dockerfile @@ -14,6 +14,7 @@ WORKDIR /workspace COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY apps/agent-worker/package.json apps/agent-worker/package.json +COPY apps/artifact-worker/package.json apps/artifact-worker/package.json COPY apps/gateway-worker/package.json apps/gateway-worker/package.json COPY apps/preview-proxy/package.json apps/preview-proxy/package.json COPY apps/web/package.json apps/web/package.json @@ -26,6 +27,7 @@ COPY packages/composio/package.json packages/composio/package.json COPY packages/db/package.json packages/db/package.json COPY packages/durable-storage/package.json packages/durable-storage/package.json COPY packages/env/package.json packages/env/package.json +COPY packages/morph/package.json packages/morph/package.json COPY packages/observability/package.json packages/observability/package.json COPY packages/preview-bridge/package.json packages/preview-bridge/package.json COPY packages/sandbox-contracts/package.json packages/sandbox-contracts/package.json diff --git a/package.json b/package.json index fbd0665e..058d1441 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "prepare": "lefthook install", "skills:build": "turbo skills:build", "typecheck": "turbo typecheck && pnpm typecheck:scripts", - "typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit" + "typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit", + "worker:performance-budgets": "tsx scripts/check-worker-performance-budgets.ts" }, "devDependencies": { "@biomejs/biome": "catalog:", diff --git a/packages/agent-core/src/mastra/durable-agent-step.ts b/packages/agent-core/src/mastra/durable-agent-step.ts index 55590f7e..fb847d6a 100644 --- a/packages/agent-core/src/mastra/durable-agent-step.ts +++ b/packages/agent-core/src/mastra/durable-agent-step.ts @@ -40,6 +40,7 @@ interface GenerateGeneralAgentStepOptions { includedTools?: readonly CheatcodeToolName[]; isDeepSeek: boolean; messages: JSONValue[]; + onTextDelta?: (delta: string) => Promise; requestContext: RequestContext; runId: string; } @@ -62,7 +63,7 @@ export async function generateGeneralAgentStep( ): Promise { const messages = options.messages.map((message) => modelMessageSchema.parse(message)); const clientTools = resolveClientTools(options); - const result = await mastra.getAgent("generalStep").generate(messages as never, { + const result = await mastra.getAgent("generalStep").stream(messages as never, { ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}), clientTools, ...(options.isDeepSeek @@ -74,12 +75,23 @@ export async function generateGeneralAgentStep( requestContext: options.requestContext, runId: options.runId, }); + for await (const chunk of result.fullStream) { + if (chunk.type === "text-delta" && chunk.payload.text.length > 0) { + await options.onTextDelta?.(chunk.payload.text); + } + } + const [finishReason, response, text, toolCalls] = await Promise.all([ + result.finishReason, + result.response, + result.text, + result.toolCalls, + ]); return { - finishReason: GeneralAgentFinishReasonSchema.parse(result.finishReason), - responseMessages: toJsonValues(result.response.messages ?? []), - text: result.text, - toolCalls: result.toolCalls.map((call) => ({ - input: toJsonValue(call.payload.args), + finishReason: GeneralAgentFinishReasonSchema.parse(finishReason), + responseMessages: toJsonValues(response.messages ?? []), + text, + toolCalls: toolCalls.map((call) => ({ + input: toJsonValue(call.payload.args ?? null), toolCallId: call.payload.toolCallId, toolName: call.payload.toolName, })), diff --git a/packages/auth/README.md b/packages/auth/README.md index d76a9704..dd8cbaaf 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -11,6 +11,8 @@ Shared authentication and signed-capability protocols for Workers. - `updateClerkUserPublicMetadata` - `hmacSha256Base64` - `timingSafeEqual` +- `createOutputDownloadCapability` +- `verifySignedOutputDownload` - `mintPreviewCapability` - `verifyPreviewCapability` - `PreviewCapabilityError` @@ -22,6 +24,9 @@ The shared verifier rejects legacy formats, oversized inputs, future-issued claims outside the protocol tolerance, and excessive lifetimes. Every shared HMAC operation rejects secrets shorter than 32 UTF-8 bytes before key import; configuration errors never include the secret value. +Output-download capabilities are one-hour, user/output-bound HMAC credentials; +their public URL remains the gateway so moving byte delivery between internal +Workers does not alter the browser contract. Clerk user and JWKS reads use the documented Backend REST API with 10-second deadlines and pre-parse response ceilings. The canonical sync snapshot validates diff --git a/packages/auth/package.json b/packages/auth/package.json index 401dd717..d3fcbf6c 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@cheatcode/observability": "workspace:*", + "@cheatcode/types": "workspace:*", "@clerk/backend": "catalog:", "zod": "catalog:" }, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index dda59a12..f55c0440 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -12,6 +12,11 @@ export { hmacSha256Base64, timingSafeEqual, } from "./crypto"; +export { + createOutputDownloadCapability, + OutputDownloadQuerySchema, + verifySignedOutputDownload, +} from "./output-download"; export type { PreviewCapabilityKind, VerifiedPreviewCapability, diff --git a/apps/agent-worker/src/output-download.ts b/packages/auth/src/output-download.ts similarity index 100% rename from apps/agent-worker/src/output-download.ts rename to packages/auth/src/output-download.ts diff --git a/packages/db/README.md b/packages/db/README.md index c93a1c23..9e822e9e 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -22,8 +22,9 @@ Administrative migration credentials are never exported by this package or loaded by an application process. Self-hosted and local environments use a dedicated Supabase project. The three -runtime URLs target that project's shared session pooler on port 5432 with -role-qualified usernames (`app_.`). There is no local +runtime URLs target that project's Direct endpoint on port 5432 with +least-privilege usernames (`app_`); Hyperdrive owns connection pooling. +There is no local Postgres mode and runtime code never receives the Supabase admin connection. ## Current schema @@ -159,6 +160,12 @@ pnpm --filter @cheatcode/db build Runtime callers supply their Worker-specific Hyperdrive binding and matching tenant-context signing secret. Migration tooling uses: +Query caching stays disabled on every current runtime Hyperdrive configuration: +all present reads are tenant-, auth-, billing-, or RLS-sensitive. A cache-enabled +configuration must be separate and may be introduced only with a dedicated +unauthenticated public catalog/reference Worker whose staleness is explicit; +there is no eligible public database surface in the current architecture. + - `SUPABASE_MIGRATION_URL` - `SUPABASE_MIGRATION_EXPECTED_HOST` - `SUPABASE_MIGRATION_EXPECTED_DATABASE` diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index fa13292d..47118c5c 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -28,6 +28,12 @@ export interface DatabaseHandle { close: () => Promise; } +type DatabaseTimingPhase = "connection" | "context" | "query"; + +interface DatabaseHandleOptions { + onTiming?: (phase: DatabaseTimingPhase, durationMs: number) => void; +} + export type UserContextSource = DatabaseHandle | UserContextDatabase; type DatabaseEnvironment = @@ -53,10 +59,15 @@ type ContextSigner = ReturnType; type CloseDatabaseHandle = (handle: DatabaseHandle) => Promise; const DATABASE_CONTEXT_SIGNERS = new WeakMap(); +const DATABASE_TIMING_OBSERVERS = new WeakMap< + Database, + NonNullable +>(); function createDb( hyperdrive: HyperdriveConnection, contextConfig: DatabaseContextConfig, + options?: DatabaseHandleOptions, ): DatabaseHandle { const pool = new Pool({ connectionString: hyperdrive.connectionString, @@ -68,12 +79,16 @@ function createDb( const db = drizzle(pool, { schema }); DATABASE_CONTEXT_SIGNERS.set(db, createDatabaseContextSigner(contextConfig)); + if (options?.onTiming) DATABASE_TIMING_OBSERVERS.set(db, options.onTiming); return { db, close: () => closeDatabase(db, pool) }; } /** Creates a request-scoped database handle that the caller must close. */ -export function createDatabaseHandle(env: DatabaseEnvironment): DatabaseHandle { - return createDb(env.HYPERDRIVE, databaseContextConfig(env)); +export function createDatabaseHandle( + env: DatabaseEnvironment, + options?: DatabaseHandleOptions, +): DatabaseHandle { + return createDb(env.HYPERDRIVE, databaseContextConfig(env), options); } export async function withUserContext( @@ -86,19 +101,46 @@ export async function withUserContext( if (!signer) { throw new Error("Database handle is missing its signed tenant-context configuration"); } + const observer = DATABASE_TIMING_OBSERVERS.get(db); + const contextStartedAt = performance.now(); const context = await signer.sign(internalUserId); + let contextDurationMs = performance.now() - contextStartedAt; + const connectionStartedAt = performance.now(); return db.transaction(async (tx) => { + emitDatabaseTiming(observer, "connection", performance.now() - connectionStartedAt); const transaction = tx as unknown as UserContextDatabase; DATABASE_CONTEXT_SIGNERS.set(transaction, signer); + if (observer) DATABASE_TIMING_OBSERVERS.set(transaction, observer); try { + const setupStartedAt = performance.now(); await setSignedContext(transaction, context); - return await fn(transaction); + contextDurationMs += performance.now() - setupStartedAt; + emitDatabaseTiming(observer, "context", contextDurationMs); + const operationStartedAt = performance.now(); + try { + return await fn(transaction); + } finally { + emitDatabaseTiming(observer, "query", performance.now() - operationStartedAt); + } } finally { DATABASE_CONTEXT_SIGNERS.delete(transaction); + DATABASE_TIMING_OBSERVERS.delete(transaction); } }); } +function emitDatabaseTiming( + observer: DatabaseHandleOptions["onTiming"], + phase: DatabaseTimingPhase, + durationMs: number, +): void { + try { + observer?.(phase, durationMs); + } catch { + // Performance observation is outside the database correctness boundary. + } +} + export async function withDatabase( env: DatabaseEnvironment, fn: (handle: DatabaseHandle) => Promise, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2823c5e8..3244f1dc 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -67,6 +67,8 @@ export { listUserDeletionRunPage, loadUserDeletionContext, } from "./lifecycle"; +export type { NavigationProjectRecord } from "./navigation-bootstrap"; +export { loadNavigationBootstrap } from "./navigation-bootstrap"; export type { ReferencedProjectGeneratedOutputRecord } from "./outputs"; export { findGeneratedOutput, diff --git a/packages/db/src/navigation-bootstrap.ts b/packages/db/src/navigation-bootstrap.ts new file mode 100644 index 00000000..3d0c0f58 --- /dev/null +++ b/packages/db/src/navigation-bootstrap.ts @@ -0,0 +1,99 @@ +import type { ProjectId, ThreadId, UserId } from "@cheatcode/types"; +import { toProjectId, toThreadId } from "@cheatcode/types"; +import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import type { Database } from "./client"; +import { projectSummaryFromRow } from "./project-mappers"; +import type { ProjectRecord } from "./project-types"; +import { projects, threads } from "./schema"; +import { listRecentThreads, type WorkspaceThreadSearchRecord } from "./search"; + +export interface NavigationProjectRecord extends ProjectRecord { + latestThreadId: ThreadId | null; +} + +interface NavigationBootstrapRecord { + activeProjectId: ProjectId | null; + projects: NavigationProjectRecord[]; + recentThreads: WorkspaceThreadSearchRecord[]; +} + +/** Loads the complete bounded sidebar snapshot in two queries inside one RLS transaction. */ +export async function loadNavigationBootstrap( + db: Database, + input: { activeThreadId?: ThreadId; userId: UserId }, +): Promise { + const recentThreads = await listRecentThreads(db, input.userId, 20); + const projectRows = await listNavigationProjects(db, input); + const activeProjectId = projectRows[0]?.activeProjectId ?? null; + return { + activeProjectId: activeProjectId ? toProjectId(activeProjectId) : null, + projects: projectRows.map(navigationProjectFromRow), + recentThreads, + }; +} + +function listNavigationProjects( + db: Database, + input: { activeThreadId?: ThreadId; userId: UserId }, +) { + const activeProjectId = activeProjectIdExpression(input); + return db + .select({ + activeProjectId, + archiveAfter: projects.archiveAfter, + createdAt: projects.createdAt, + id: projects.id, + latestThreadId: latestThreadIdExpression(input.userId), + mode: projects.mode, + name: projects.name, + overQuota: projects.overQuota, + settings: projects.settings, + updatedAt: projects.updatedAt, + workspaceSlug: projects.workspaceSlug, + }) + .from(projects) + .where(and(eq(projects.userId, input.userId), isNull(projects.deletedAt))) + .orderBy( + sql`case when ${projects.id} = ${activeProjectId} then 0 else 1 end`, + desc(projects.updatedAt), + desc(projects.id), + ) + .limit(6); +} + +function activeProjectIdExpression(input: { activeThreadId?: ThreadId; userId: UserId }) { + if (!input.activeThreadId) return sql`null::uuid`; + return sql`( + select active_thread.project_id + from ${threads} active_thread + join ${projects} active_project + on active_project.id = active_thread.project_id + and active_project.user_id = ${input.userId} + and active_project.deleted_at is null + where active_thread.id = ${input.activeThreadId} + and active_thread.user_id = ${input.userId} + and active_thread.deleted_at is null + limit 1 + )`; +} + +function latestThreadIdExpression(userId: UserId) { + return sql`( + select latest_thread.id + from ${threads} latest_thread + where latest_thread.project_id = ${projects.id} + and latest_thread.user_id = ${userId} + and latest_thread.deleted_at is null + order by latest_thread.updated_at desc, latest_thread.id desc + limit 1 + )`; +} + +function navigationProjectFromRow( + row: Awaited>[number], +): NavigationProjectRecord { + return { + ...projectSummaryFromRow(row), + latestThreadId: row.latestThreadId ? toThreadId(row.latestThreadId) : null, + }; +} diff --git a/packages/env/README.md b/packages/env/README.md index c2d4c1af..51ec04da 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -21,9 +21,9 @@ pnpm --filter @cheatcode/env typecheck ## Env See root `.env.example` for the local application contract. `pnpm dev:setup` -assembles project-agnostic URLs for a dedicated Supabase project's public -session pooler on port 5432, using the three least-privilege runtime roles. -Direct endpoints and transaction pooling are rejected for runtime connections. +assembles URLs for a dedicated Supabase project's Direct endpoint on port 5432, +using the three least-privilege runtime roles. Pooler endpoints are rejected for +runtime connections because Hyperdrive already owns connection pooling. Administrative migration values live separately in git-ignored `.env.migrate` (template: `.env.migrate.example`) or protected automation environment variables and are never loaded by the app or copied into a Worker. diff --git a/packages/env/src/agent-worker.ts b/packages/env/src/agent-worker.ts index 256ed1dd..298f7bd5 100644 --- a/packages/env/src/agent-worker.ts +++ b/packages/env/src/agent-worker.ts @@ -34,8 +34,6 @@ export const AgentWorkerEnvSchema = z DEEPSEEK_PLATFORM_API_KEY: OptionalWorkerSecretSchema, HYPERDRIVE: HyperdriveSchema, MORPH_API_KEY: WorkerSecretSchema, - OUTPUT_DOWNLOAD_BASE_URL: z.string().url().optional(), - OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecretSchema, PREVIEW_HOSTNAME: PreviewHostnameSchema.optional(), PREVIEW_TOKEN_SECRET: WorkerSecretSchema, PROJECT_SANDBOX: DurableObjectNamespaceBindingSchema, diff --git a/packages/env/src/artifact-worker.ts b/packages/env/src/artifact-worker.ts new file mode 100644 index 00000000..00e26e2f --- /dev/null +++ b/packages/env/src/artifact-worker.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { + AnalyticsBindingsSchema, + HyperdriveSchema, + R2BucketBindingSchema, + requireProductionReleaseSha, + WorkerReleaseBindingsSchema, + WorkerSecretSchema, +} from "./worker-shared"; + +export const ArtifactWorkerEnvSchema = z + .strictObject({ + ...AnalyticsBindingsSchema, + ...WorkerReleaseBindingsSchema, + DATABASE_CONTEXT_SIGNING_SECRET_AGENT: WorkerSecretSchema, + HYPERDRIVE: HyperdriveSchema, + OUTPUT_DOWNLOAD_BASE_URL: z.string().url().optional(), + OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecretSchema, + R2_OUTPUTS: R2BucketBindingSchema, + }) + .superRefine(requireProductionReleaseSha); diff --git a/packages/env/src/gateway-worker.ts b/packages/env/src/gateway-worker.ts index 07b6f9f9..b70b7d78 100644 --- a/packages/env/src/gateway-worker.ts +++ b/packages/env/src/gateway-worker.ts @@ -7,6 +7,7 @@ import { HyperdriveSchema, KvNamespaceBindingSchema, OptionalWorkerSecretSchema, + RateLimitBindingSchema, requireProductionReleaseSha, WorkerReleaseBindingsSchema, WorkerSecretSchema, @@ -17,6 +18,7 @@ export const GatewayWorkerEnvSchema = z ...AnalyticsBindingsSchema, ...WorkerReleaseBindingsSchema, AGENT: FetcherBindingSchema, + ARTIFACTS: FetcherBindingSchema, CLERK_AUTHORIZED_PARTIES: z.string().trim().min(1).max(2_048).optional(), CLERK_SECRET_KEY: OptionalWorkerSecretSchema, COMPOSIO_API_KEY: OptionalWorkerSecretSchema, @@ -31,6 +33,8 @@ export const GatewayWorkerEnvSchema = z POLAR_SERVER: z.enum(["production", "sandbox"]).optional(), PREVIEW_PROXY: FetcherBindingSchema.optional(), QUOTA_TRACKER: FetcherBindingSchema, + RATE_LIMIT_PUBLIC_READ: RateLimitBindingSchema, + RATE_LIMIT_READ_CHEAP: RateLimitBindingSchema, RATE_LIMITER: DurableObjectNamespaceBindingSchema, RESOURCE_DELETION: FetcherBindingSchema, WEBHOOKS: FetcherBindingSchema, diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index b442576f..a42ec4a7 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -5,6 +5,7 @@ export { export type { CloudflareVersionMetadata, PreviewProxyEnv, WorkerSecret } from "./worker"; export { AgentWorkerEnvSchema, + ArtifactWorkerEnvSchema, DEFAULT_DAYTONA_TARGET, GatewayWorkerEnvSchema, PreviewProxyEnvSchema, diff --git a/packages/env/src/worker-shared.ts b/packages/env/src/worker-shared.ts index 8c9c190b..4f662260 100644 --- a/packages/env/src/worker-shared.ts +++ b/packages/env/src/worker-shared.ts @@ -37,6 +37,10 @@ function isKvNamespaceBinding(value: unknown): value is KVNamespace { return hasBindingMethods(value, ["get", "put", "delete", "list"]); } +function isRateLimitBinding(value: unknown): value is RateLimit { + return hasBindingMethods(value, ["limit"]); +} + function isDurableObjectNamespaceBinding(value: unknown): value is DurableObjectNamespace { return hasBindingMethods(value, [ "newUniqueId", @@ -93,6 +97,10 @@ export const R2BucketBindingSchema = z.custom( isR2BucketBinding, "Expected a Cloudflare R2 bucket binding", ); +export const RateLimitBindingSchema = z.custom( + isRateLimitBinding, + "Expected a Cloudflare Rate Limiting binding", +); export const WorkflowBindingSchema = z.custom>( isWorkflowBinding, "Expected a Cloudflare Workflow binding", diff --git a/packages/env/src/worker.ts b/packages/env/src/worker.ts index b568d00f..964683b7 100644 --- a/packages/env/src/worker.ts +++ b/packages/env/src/worker.ts @@ -1,4 +1,5 @@ export { AgentWorkerEnvSchema } from "./agent-worker"; +export { ArtifactWorkerEnvSchema } from "./artifact-worker"; export { GatewayWorkerEnvSchema } from "./gateway-worker"; export { type PreviewProxyEnv, PreviewProxyEnvSchema } from "./preview-proxy"; export { WebhooksWorkerEnvSchema } from "./webhooks-worker"; diff --git a/packages/observability/README.md b/packages/observability/README.md index 8859b2e8..77e0bd97 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -3,8 +3,17 @@ Structured logging, redaction, error response helpers, and Workers Analytics Engine emitters. -Performance metrics use the `cc_performance_metrics` column order enforced in `src/analytics.ts`: -`ttftMs`, `totalMs`, `dbQueryMs`, `sandboxMs`, `llmMs`, and `queueWaitMs`. +Performance metrics use the locked `cc_performance_metrics` column order enforced in +`src/analytics.ts`. `index1` is route. Blobs 1-12 are worker, environment, +release/version, status class, metric name, colo, placement, reconnect state, +provider, logical model, experiment variant, and cold-state label. Doubles 1-20 +are legacy TTFT, total, DB query, sandbox, LLM, queue wait, secret resolution, +JWT verification, user lookup, rate limit, DB connection, DB context, Service +Binding, Durable Object, Workflow acceptance, response headers, first status, +first model text, final token, and run completion. +The generic Worker middleware labels the first request observed by each isolate +as `cold` and subsequent requests as `warm`; it is an isolate-start correlation +signal, not a claim that Cloudflare created a new process or machine. User funnel events use the locked `cc_user_events` order. Blob 9 contains the planned logical model for admission events and the resolved logical model for stream-attempt/completion events. Pre-attempt failures retain planned attribution; @@ -34,6 +43,8 @@ positions. event, and optional performance-metric boundary per Worker - `createPerformanceMetricMiddleware`, `requestId`, `routeName`, and `routeWorkerError` +- `createPerformanceRecorder` and `safeServerTiming` for request-scoped phase + measurement and a safe aggregate browser header - bounded request/response readers: `readJsonRequest`, `readBoundedRequestBytes`, `readBoundedRequestText`, `readBoundedResponseText`, and `readBoundedResponseJson` - `withBoundedResponseBody` for enforcing response limits before an SDK parser @@ -44,6 +55,9 @@ Analytics emitters are best-effort by contract: a missing binding, account quota, or per-invocation write allowance cannot fail the product operation that produced the telemetry. Callers therefore do not add local error handling or make correctness decisions from an Analytics Engine write. +Percentile query templates and dashboard column aliases live under +`infra/cloudflare/analytics`; weighted quantiles account for Analytics Engine +sampling and filter unset zero placeholders per metric. Error Analytics Engine rows intentionally contain only categorical metadata. Raw error messages and stack traces are never written to Analytics Engine, and diff --git a/packages/observability/src/analytics.ts b/packages/observability/src/analytics.ts index e4888fe5..95d6c183 100644 --- a/packages/observability/src/analytics.ts +++ b/packages/observability/src/analytics.ts @@ -100,18 +100,39 @@ export interface ErrorEvent { workerName: string; } -interface PerformanceMetric { +export interface PerformanceMetric { + authSecretMs?: number; + authVerificationMs?: number; + coldState?: string; + colo?: string; + dbConnectionMs?: number; + dbContextMs?: number; dbQueryMs?: number; + durableObjectMs?: number; envTag?: string; + finalTokenMs?: number; + firstModelTextMs?: number; + firstStatusMs?: number; llmMs?: number; + logicalModelId?: LogicalModelId; metricName?: string; + placement?: string; + provider?: string; queueWaitMs?: number; + rateLimitMs?: number; + reconnectState?: "new" | "reconnect"; + responseHeadersMs?: number; route: string; + runCompletionMs?: number; sandboxMs?: number; + serviceBindingMs?: number; statusClass: string; totalMs?: number; ttftMs?: number; + userLookupMs?: number; + variant?: string; versionTag?: string; + workflowAcceptanceMs?: number; workerName: string; } @@ -215,6 +236,13 @@ export function emitPerformanceMetric(env: AnalyticsBindings, metric: Performanc metric.versionTag, metric.statusClass, metric.metricName, + metric.colo, + metric.placement, + metric.reconnectState, + metric.provider, + metric.logicalModelId, + metric.variant, + metric.coldState, ], doubles: [ metric.ttftMs, @@ -223,6 +251,20 @@ export function emitPerformanceMetric(env: AnalyticsBindings, metric: Performanc metric.sandboxMs, metric.llmMs, metric.queueWaitMs, + metric.authSecretMs, + metric.authVerificationMs, + metric.userLookupMs, + metric.rateLimitMs, + metric.dbConnectionMs, + metric.dbContextMs, + metric.serviceBindingMs, + metric.durableObjectMs, + metric.workflowAcceptanceMs, + metric.responseHeadersMs, + metric.firstStatusMs, + metric.firstModelTextMs, + metric.finalTokenMs, + metric.runCompletionMs, ], }); } diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 18727623..eda10baf 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -1,4 +1,4 @@ -export type { AgentMetric, AnalyticsBindings } from "./analytics"; +export type { AgentMetric, AnalyticsBindings, PerformanceMetric } from "./analytics"; export { emitAgentMetric, emitErrorEvent, @@ -16,6 +16,8 @@ export { } from "./http-json"; export type { Logger } from "./logger"; export { createLogger } from "./logger"; +export type { PerformanceRecorder } from "./performance"; +export { createPerformanceRecorder, safeServerTiming } from "./performance"; export { redactSecrets } from "./redact"; export { createPerformanceMetricMiddleware, diff --git a/packages/observability/src/performance.ts b/packages/observability/src/performance.ts new file mode 100644 index 00000000..a71d0e57 --- /dev/null +++ b/packages/observability/src/performance.ts @@ -0,0 +1,114 @@ +type PerformancePhase = + | "authSecret" + | "authVerification" + | "databaseConnection" + | "databaseContext" + | "databaseQuery" + | "durableObject" + | "finalToken" + | "firstModelText" + | "firstStatus" + | "rateLimit" + | "responseHeaders" + | "runCompletion" + | "serviceBinding" + | "userLookup" + | "workflowAcceptance"; + +interface PerformanceTimingFields { + authSecretMs?: number; + authVerificationMs?: number; + dbConnectionMs?: number; + dbContextMs?: number; + dbQueryMs?: number; + durableObjectMs?: number; + finalTokenMs?: number; + firstModelTextMs?: number; + firstStatusMs?: number; + rateLimitMs?: number; + responseHeadersMs?: number; + runCompletionMs?: number; + serviceBindingMs?: number; + userLookupMs?: number; + workflowAcceptanceMs?: number; +} + +export interface PerformanceRecorder { + add(phase: PerformancePhase, durationMs: number): void; + elapsedMs(): number; + measure(phase: PerformancePhase, operation: () => Promise): Promise; + snapshot(): PerformanceTimingFields; +} + +const PHASE_FIELDS = { + authSecret: "authSecretMs", + authVerification: "authVerificationMs", + databaseConnection: "dbConnectionMs", + databaseContext: "dbContextMs", + databaseQuery: "dbQueryMs", + durableObject: "durableObjectMs", + finalToken: "finalTokenMs", + firstModelText: "firstModelTextMs", + firstStatus: "firstStatusMs", + rateLimit: "rateLimitMs", + responseHeaders: "responseHeadersMs", + runCompletion: "runCompletionMs", + serviceBinding: "serviceBindingMs", + userLookup: "userLookupMs", + workflowAcceptance: "workflowAcceptanceMs", +} as const satisfies Record; + +export function createPerformanceRecorder(now = () => performance.now()): PerformanceRecorder { + const startedAt = now(); + const durations = new Map(); + return { + add(phase, durationMs) { + if (!Number.isFinite(durationMs) || durationMs < 0) return; + durations.set(phase, (durations.get(phase) ?? 0) + durationMs); + }, + elapsedMs: () => Math.max(0, now() - startedAt), + async measure(phase, operation) { + const phaseStartedAt = now(); + try { + return await operation(); + } finally { + this.add(phase, Math.max(0, now() - phaseStartedAt)); + } + }, + snapshot() { + const fields: PerformanceTimingFields = {}; + for (const [phase, durationMs] of durations) { + fields[PHASE_FIELDS[phase]] = durationMs; + } + return fields; + }, + }; +} + +export function safeServerTiming(recorder: PerformanceRecorder): string { + const timing = recorder.snapshot(); + return [ + serverTimingEntry( + "auth", + sum(timing.authSecretMs, timing.authVerificationMs, timing.userLookupMs), + ), + serverTimingEntry("rate", timing.rateLimitMs), + serverTimingEntry("db", sum(timing.dbConnectionMs, timing.dbContextMs, timing.dbQueryMs)), + serverTimingEntry( + "upstream", + sum(timing.serviceBindingMs, timing.durableObjectMs, timing.workflowAcceptanceMs), + ), + serverTimingEntry("edge", recorder.elapsedMs()), + ] + .filter((entry): entry is string => entry !== undefined) + .join(", "); +} + +function sum(...values: Array): number | undefined { + const present = values.filter((value): value is number => value !== undefined); + return present.length === 0 ? undefined : present.reduce((total, value) => total + value, 0); +} + +function serverTimingEntry(name: string, durationMs: number | undefined): string | undefined { + return durationMs === undefined ? undefined : `${name};dur=${durationMs.toFixed(1)}`; +} diff --git a/packages/observability/src/worker-runtime.ts b/packages/observability/src/worker-runtime.ts index 67c25195..06ce84f9 100644 --- a/packages/observability/src/worker-runtime.ts +++ b/packages/observability/src/worker-runtime.ts @@ -1,7 +1,8 @@ -import type { AnalyticsBindings } from "./analytics"; +import type { AnalyticsBindings, PerformanceMetric } from "./analytics"; import { emitErrorEvent, emitPerformanceMetric } from "./analytics"; import { safeErrorTelemetry, toAPIError } from "./errors"; import { createLogger } from "./logger"; +import type { PerformanceRecorder } from "./performance"; const ROUTED_WORKER_ERROR = Symbol("routed-worker-error"); @@ -40,7 +41,12 @@ interface PerformanceContext { } interface PerformanceMiddlewareOptions { + decorateResponse?: (context: Context, recorder: PerformanceRecorder | undefined) => void; errorStatus?: (error: unknown) => number; + metricFields?: ( + context: Context, + ) => Partial>; + recorder?: (context: Context) => PerformanceRecorder | undefined; routeName: (context: Context) => string; workerName: string; } @@ -150,7 +156,10 @@ export function createPerformanceMetricMiddleware< >( options: PerformanceMiddlewareOptions, ): (context: Context, next: () => Promise) => Promise { + let hasHandledRequest = false; return async (context, next) => { + const coldState = hasHandledRequest ? "warm" : "cold"; + hasHandledRequest = true; const startedAt = performance.now(); let status = 500; try { @@ -161,10 +170,16 @@ export function createPerformanceMetricMiddleware< status = options.errorStatus?.(sourceError) ?? toAPIError(sourceError).status; throw error; } finally { + const recorder = options.recorder?.(context); + recorder?.add("responseHeaders", recorder.elapsedMs()); + options.decorateResponse?.(context, recorder); emitPerformanceMetric(context.env, { + ...options.metricFields?.(context), + ...recorder?.snapshot(), + coldState, route: options.routeName(context), statusClass: statusClass(status), - totalMs: performance.now() - startedAt, + totalMs: recorder?.elapsedMs() ?? performance.now() - startedAt, workerName: options.workerName, }); } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index f0b31a73..99816460 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -535,6 +535,26 @@ export const RecentThreadsResponseSchema = z.strictObject({ threads: z.array(SearchResultThreadSchema), }); +/** One sidebar project plus the newest chat used by its direct navigation link. */ +const NavigationBootstrapProjectSchema = ProjectSummarySchema.extend({ + latestThreadId: z.string().uuid().nullable(), +}); + +/** + * The bounded navigation snapshot loaded after Clerk becomes ready. The active + * project is promoted into `projects` when it would otherwise fall outside the + * six most recently updated projects. + */ +export const NavigationBootstrapQuerySchema = z.strictObject({ + activeThreadId: z.string().uuid().optional(), +}); + +export const NavigationBootstrapResponseSchema = z.strictObject({ + activeProjectId: z.string().uuid().nullable(), + projects: z.array(NavigationBootstrapProjectSchema).max(6), + recentThreads: z.array(SearchResultThreadSchema).max(20), +}); + export const GreetingResponseSchema = z.strictObject({ city: z.string().nullable(), timezone: z.string().nullable(), @@ -584,6 +604,8 @@ export type CreateRun = z.infer; export type CreateThread = z.infer; export type RunIntent = z.infer; export type GreetingResponse = z.infer; +export type NavigationBootstrapProject = z.infer; +export type NavigationBootstrapResponse = z.infer; export type SearchResponse = z.infer; export type SearchResult = z.infer; export type SearchResultThread = z.infer; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 570f9278..a1552e1a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -62,6 +62,7 @@ export { sandboxFileEntryShape } from "./sandbox-wire"; export type { SkillRuntimeScope } from "./skill-runtime"; export { SkillRuntimeScopeSchema } from "./skill-runtime"; export { + BrowserPerformanceMetricNameSchema, ClientErrorBodySchema, ClientUserEventBodySchema, normalizeTelemetryPath, diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 242173cd..32168392 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -34,11 +34,23 @@ export const ClientErrorBodySchema = z.strictObject({ url: z.string().max(2_000).optional(), }); +export const BrowserPerformanceMetricNameSchema = z.enum([ + "CLS", + "FCP", + "INP", + "LCP", + "TTFB", + "run_response_headers", + "run_first_status", + "run_first_model_text", + "run_stream_finished", +]); + const WebVitalMetricSchema = z.strictObject({ attributionTarget: z.string().max(1_000).optional(), delta: z.number().finite().optional(), id: z.string().max(200), - name: z.string().max(40), + name: BrowserPerformanceMetricNameSchema, navigationType: z.string().max(80).optional(), rating: z.enum(["good", "needs-improvement", "poor"]).optional(), url: z.string().max(2_000).optional(), diff --git a/packages/types/src/ui-message.ts b/packages/types/src/ui-message.ts index 77033a04..c1a583bc 100644 --- a/packages/types/src/ui-message.ts +++ b/packages/types/src/ui-message.ts @@ -19,6 +19,17 @@ const ModelFallbackDataSchema = z.strictObject({ reason: z.enum(["rate_limit", "provider_balance", "provider_error"]), }); +/** Replaceable live model text; transient events never enter the durable transcript. */ +const ModelProvisionalDataSchema = z.discriminatedUnion("phase", [ + z.strictObject({ + delta: z.string().min(1).max(8_192), + phase: z.literal("delta"), + streamId: z.string().min(1).max(100), + v: z.literal(1), + }), + z.strictObject({ phase: z.literal("reset"), v: z.literal(1) }), +]); + /* retained for historical transcripts */ const PlanDataSchema = z.strictObject({ v: z.literal(1), @@ -123,6 +134,7 @@ export const CHEATCODE_DATA_SCHEMAS = { artifact: ArtifactDataSchema, error: ErrorDataSchema, "model-fallback": ModelFallbackDataSchema, + "model-provisional": ModelProvisionalDataSchema, plan: PlanDataSchema, "project-created": ProjectCreatedDataSchema, "run-intent": RunIntentDataSchema, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16e316c4..79acc252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,6 +330,43 @@ importers: specifier: 'catalog:' version: 4.114.0(@cloudflare/workers-types@5.20260722.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/artifact-worker: + dependencies: + '@cheatcode/auth': + specifier: workspace:* + version: link:../../packages/auth + '@cheatcode/db': + specifier: workspace:* + version: link:../../packages/db + '@cheatcode/env': + specifier: workspace:* + version: link:../../packages/env + '@cheatcode/observability': + specifier: workspace:* + version: link:../../packages/observability + '@cheatcode/types': + specifier: workspace:* + version: link:../../packages/types + hono: + specifier: 'catalog:' + version: 4.12.34 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@cheatcode/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 5.20260722.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + wrangler: + specifier: 'catalog:' + version: 4.114.0(@cloudflare/workers-types@5.20260722.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/gateway-worker: dependencies: '@cheatcode/auth': @@ -646,6 +683,9 @@ importers: '@cheatcode/observability': specifier: workspace:* version: link:../observability + '@cheatcode/types': + specifier: workspace:* + version: link:../types '@clerk/backend': specifier: 'catalog:' version: 3.11.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) diff --git a/scripts/check-worker-performance-budgets.ts b/scripts/check-worker-performance-budgets.ts new file mode 100644 index 00000000..89ab9e7c --- /dev/null +++ b/scripts/check-worker-performance-budgets.ts @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface WorkerBudget { + directory: string; + gzipKiB: number; +} + +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const WORKER_BUDGETS: readonly WorkerBudget[] = [ + { directory: "apps/agent-worker", gzipKiB: 2_600 }, + { directory: "apps/artifact-worker", gzipKiB: 240 }, + { directory: "apps/gateway-worker", gzipKiB: 475 }, + { directory: "apps/preview-proxy", gzipKiB: 150 }, + { directory: "apps/webhooks-worker", gzipKiB: 475 }, +]; +const AGENT_STARTUP_BUDGET_MS = 900; + +for (const budget of WORKER_BUDGETS) { + const output = runWrangler(budget.directory, ["deploy", "--dry-run"]); + const gzipKiB = parseGzipKiB(output, budget.directory); + if (gzipKiB > budget.gzipKiB) { + throw new Error( + `${budget.directory} gzip upload ${gzipKiB.toFixed(2)} KiB exceeds ${budget.gzipKiB} KiB`, + ); + } + console.info(`${budget.directory}: ${gzipKiB.toFixed(2)} / ${budget.gzipKiB} KiB gzip`); +} + +const profileDirectory = mkdtempSync(join(tmpdir(), "cheatcode-worker-startup-")); +try { + const profilePath = join(profileDirectory, "agent.cpuprofile"); + runWrangler("apps/agent-worker", ["check", "startup", "--outfile", profilePath]); + const startupMs = readProfileDurationMs(profilePath); + if (startupMs > AGENT_STARTUP_BUDGET_MS) { + throw new Error( + `apps/agent-worker local startup profile ${startupMs.toFixed(2)} ms exceeds ${AGENT_STARTUP_BUDGET_MS} ms`, + ); + } + console.info( + `apps/agent-worker: ${startupMs.toFixed(2)} / ${AGENT_STARTUP_BUDGET_MS} ms local startup profile`, + ); +} finally { + rmSync(profileDirectory, { force: true, recursive: true }); +} + +function runWrangler(directory: string, args: readonly string[]): string { + const result = spawnSync("pnpm", ["exec", "wrangler", ...args], { + cwd: resolve(ROOT, directory), + encoding: "utf8", + env: process.env, + maxBuffer: 32 * 1024 * 1024, + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.status !== 0) { + throw new Error(`${directory} wrangler ${args.join(" ")} failed\n${output}`); + } + return output; +} + +function parseGzipKiB(output: string, directory: string): number { + const match = /Total Upload:\s+[\d.]+\s+KiB\s+\/\s+gzip:\s+([\d.]+)\s+KiB/u.exec(output); + const value = match?.[1] ? Number(match[1]) : Number.NaN; + if (!Number.isFinite(value)) { + throw new Error(`Could not read Wrangler gzip size for ${directory}`); + } + return value; +} + +function readProfileDurationMs(path: string): number { + const profile = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(profile)) { + throw new Error("Wrangler startup profile is not an object"); + } + const startTime = profile["startTime"]; + const endTime = profile["endTime"]; + if (typeof startTime !== "number" || typeof endTime !== "number" || endTime < startTime) { + throw new Error("Wrangler startup profile is missing valid timestamps"); + } + return (endTime - startTime) / 1_000; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/scripts/dev-worker-config.ts b/scripts/dev-worker-config.ts index 7d329ba3..2f53a834 100644 --- a/scripts/dev-worker-config.ts +++ b/scripts/dev-worker-config.ts @@ -3,8 +3,8 @@ import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { type ConfigRecord, isRecord, parseJsoncObject } from "./jsonc"; import { + validateSupabaseDirectUrl, validateSupabaseRuntimeDatabaseUrls, - validateSupabaseSessionPoolerUrl, } from "./local-env-contract"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -13,6 +13,7 @@ const GATEWAY_WORKER_DIR = join(ROOT, "apps/gateway-worker"); const WORKER_CONFIGS = [ "wrangler.jsonc", "../agent-worker/wrangler.jsonc", + "../artifact-worker/wrangler.jsonc", "../webhooks-worker/wrangler.jsonc", "../preview-proxy/wrangler.jsonc", ] as const; @@ -27,6 +28,10 @@ const PRODUCTION_DATABASE_URL_BINDINGS: Partial< envKey: "SUPABASE_AGENT_DATABASE_URL", role: "app_agent", }, + "../artifact-worker/wrangler.jsonc": { + envKey: "SUPABASE_AGENT_DATABASE_URL", + role: "app_agent", + }, "../webhooks-worker/wrangler.jsonc": { envKey: "SUPABASE_WEBHOOKS_DATABASE_URL", role: "app_webhooks", @@ -47,9 +52,12 @@ const LOCAL_WORKER_SECRET_BINDINGS: Record = { "DAYTONA_API_KEY", "DEEPSEEK_PLATFORM_API_KEY", "MORPH_API_KEY", - "OUTPUT_DOWNLOAD_SIGNING_SECRET", "PREVIEW_TOKEN_SECRET", ], + "../artifact-worker/wrangler.jsonc": [ + "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", + "OUTPUT_DOWNLOAD_SIGNING_SECRET", + ], "../webhooks-worker/wrangler.jsonc": [ "CLERK_WEBHOOK_SIGNING_SECRET", "COMPOSIO_API_KEY", @@ -72,6 +80,7 @@ const LOCAL_WORKER_VAR_BINDINGS: Record = { "DAYTONA_TARGET", "DAYTONA_WORKSPACE_VOLUME", ], + "../artifact-worker/wrangler.jsonc": [], "../webhooks-worker/wrangler.jsonc": [ "POLAR_PRODUCT_ID_PREMIUM", "POLAR_PRODUCT_ID_PRO", @@ -150,6 +159,12 @@ function applyLocalWorkerOverrides( }, }; } + if (configPath === "../artifact-worker/wrangler.jsonc") { + return { + ...configWithLocalDatabase, + vars: { ...localVars, OUTPUT_DOWNLOAD_BASE_URL: "http://127.0.0.1:8787" }, + }; + } if (configPath !== "../agent-worker/wrangler.jsonc") { return { ...configWithLocalDatabase, vars: localVars }; } @@ -157,7 +172,6 @@ function applyLocalWorkerOverrides( ...configWithLocalDatabase, vars: { ...localVars, - OUTPUT_DOWNLOAD_BASE_URL: "http://127.0.0.1:8787", PREVIEW_HOSTNAME: "localhost:8787", }, }; @@ -237,7 +251,7 @@ function productionDatabaseConnectionString( if (!raw) { throw new Error(`.env.local is missing ${expected.envKey}.`); } - validateSupabaseSessionPoolerUrl(raw, expected.envKey, expected.role); + validateSupabaseDirectUrl(raw, expected.envKey, expected.role); return raw; } @@ -260,12 +274,15 @@ function productionVarsRemovedForLocal(configPath: WorkerConfig, vars: ConfigRec DAYTONA_SANDBOX_SNAPSHOT: _snapshot, DAYTONA_TARGET: _target, DAYTONA_WORKSPACE_VOLUME: _workspaceVolume, - OUTPUT_DOWNLOAD_BASE_URL: _outputDownloadBaseUrl, PREVIEW_HOSTNAME: _previewHostname, ...localVars } = vars; return localVars; } + if (configPath === "../artifact-worker/wrangler.jsonc") { + const { OUTPUT_DOWNLOAD_BASE_URL: _outputDownloadBaseUrl, ...localVars } = vars; + return localVars; + } if (configPath === "../preview-proxy/wrangler.jsonc") { const { CHEATCODE_APP_ORIGIN: _appOrigin, diff --git a/scripts/local-env-contract.ts b/scripts/local-env-contract.ts index 2f3ebf8c..0a6eb4f7 100644 --- a/scripts/local-env-contract.ts +++ b/scripts/local-env-contract.ts @@ -72,7 +72,7 @@ export interface LocalEnvSurface { workersOnly: boolean; } -export interface SupabasePoolerTarget { +export interface SupabaseDirectTarget { database: string; hostname: string; port: string; @@ -88,6 +88,7 @@ const RUNTIME_DATABASE_KEYS = [ const SUPABASE_PROJECT_REF_PATTERN = /^[a-z0-9]{20}$/u; const SUPABASE_POOLER_HOST_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+pooler\.supabase\.com$/u; +const SUPABASE_DIRECT_HOST_PATTERN = /^db\.([a-z0-9]{20})\.supabase\.co$/u; export function parseSupabaseProjectRef(value: string, label = "Supabase project ref"): string { if (!SUPABASE_PROJECT_REF_PATTERN.test(value)) { @@ -104,46 +105,51 @@ export function validateSupabasePoolerHost(value: string): string { return hostname; } -export function validateSupabaseSessionPoolerUrl( +export function validateSupabaseDirectUrl( raw: string, envKey: string, expectedRole: string, -): SupabasePoolerTarget { +): SupabaseDirectTarget { const url = parsePostgresUrl(raw, envKey); - const username = decodeUrlComponent(url.username, `${envKey} username`); - const separator = username.lastIndexOf("."); - const role = username.slice(0, separator); - const projectRef = parseSupabaseProjectRef( - username.slice(separator + 1), - `${envKey} project ref`, - ); - validatePoolerUrlShape(url, envKey, role, expectedRole); - return { - database: url.pathname.slice(1), - hostname: url.hostname, - port: url.port, - projectRef, - }; + const projectRef = SUPABASE_DIRECT_HOST_PATTERN.exec(url.hostname)?.[1]; + const hasTlsParameters = + url.searchParams.size === 2 && + url.searchParams.get("sslmode") === "require" && + url.searchParams.get("uselibpqcompat") === "true"; + const isValid = + decodeUrlComponent(url.username, `${envKey} username`) === expectedRole && + Boolean(url.password) && + projectRef !== undefined && + url.port === "5432" && + url.pathname === "/postgres" && + hasTlsParameters && + !url.hash; + if (!isValid || !projectRef) { + throw new Error( + `${envKey} must use ${expectedRole} on the Supabase Direct endpoint (db..supabase.co:5432/postgres) with sslmode=require and uselibpqcompat=true.`, + ); + } + return { database: "postgres", hostname: url.hostname, port: url.port, projectRef }; } export function validateSupabaseRuntimeDatabaseUrls( values: Record, -): SupabasePoolerTarget { +): SupabaseDirectTarget { const targets = RUNTIME_DATABASE_KEYS.map(([envKey, role]) => { const value = values[envKey]; if (!value) { throw new Error(`.env.local is missing ${envKey}.`); } - return [envKey, validateSupabaseSessionPoolerUrl(value, envKey, role)] as const; + return [envKey, validateSupabaseDirectUrl(value, envKey, role)] as const; }); const first = targets[0]?.[1]; if (!first) { throw new Error("Supabase runtime database URL contract is empty."); } for (const [envKey, target] of targets.slice(1)) { - if (!samePoolerTarget(first, target)) { + if (!sameRuntimeTarget(first, target)) { throw new Error( - `${envKey} must share the session-pooler host, port, database, and project ref used by all runtime database URLs.`, + `${envKey} must share the direct host, port, database, and project ref used by all runtime database URLs.`, ); } } @@ -220,31 +226,6 @@ function parsePostgresUrl(raw: string, envKey: string): URL { } } -function validatePoolerUrlShape( - url: URL, - envKey: string, - actualRole: string, - expectedRole: string, -): void { - const hasTlsParameters = - url.searchParams.size === 2 && - url.searchParams.get("sslmode") === "require" && - url.searchParams.get("uselibpqcompat") === "true"; - const isValid = - actualRole === expectedRole && - Boolean(url.password) && - SUPABASE_POOLER_HOST_PATTERN.test(url.hostname) && - url.port === "5432" && - url.pathname === "/postgres" && - hasTlsParameters && - !url.hash; - if (!isValid) { - throw new Error( - `${envKey} must use ${expectedRole}. on a Supabase session pooler (*.pooler.supabase.com:5432/postgres) with sslmode=require and uselibpqcompat=true.`, - ); - } -} - function decodeUrlComponent(value: string, label: string): string { try { return decodeURIComponent(value); @@ -253,7 +234,7 @@ function decodeUrlComponent(value: string, label: string): string { } } -function samePoolerTarget(left: SupabasePoolerTarget, right: SupabasePoolerTarget): boolean { +function sameRuntimeTarget(left: SupabaseDirectTarget, right: SupabaseDirectTarget): boolean { return ( left.hostname === right.hostname && left.port === right.port && diff --git a/scripts/setup-prompts.ts b/scripts/setup-prompts.ts index 6cd069cd..44b8076e 100644 --- a/scripts/setup-prompts.ts +++ b/scripts/setup-prompts.ts @@ -4,7 +4,6 @@ import { parseSupabaseProjectRef, type RequiredKey, validateRequiredLocalValue, - validateSupabasePoolerHost, } from "./local-env-contract"; import { SETUP_KEY_META } from "./setup-keys"; import { @@ -51,11 +50,10 @@ export async function collectSetupValues( "Use a dedicated Supabase project. Copy the project ref and Database connection values from https://supabase.com/dashboard.", ); const projectRef = await promptProjectRef(existingLocal); - const poolerHost = await promptPoolerHost(existingLocal); const adminUrl = await promptAdminUrl(existingMigrate, projectRef); const rolePasswords = await promptRolePasswords(existingLocal); const localValues = await collectApplicationValues(existingLocal); - Object.assign(localValues, runtimeDatabaseUrls(projectRef, poolerHost, rolePasswords)); + Object.assign(localValues, runtimeDatabaseUrls(projectRef, rolePasswords)); return { adminTarget: parseAdminDatabaseUrl(adminUrl, projectRef), localValues, @@ -226,22 +224,6 @@ async function promptProjectRef(existing: Record): Promise): Promise { - const value = await promptTextValue( - "Supabase session-pooler host", - inferPoolerHost(existing), - (candidate) => { - try { - validateSupabasePoolerHost(candidate); - return undefined; - } catch (error) { - return errorMessage(error); - } - }, - ); - return validateSupabasePoolerHost(value); -} - async function promptAdminUrl( existing: Record, projectRef: string, @@ -351,18 +333,24 @@ async function promptConfirm(message: string, initialValue: boolean): Promise>, ): Record { return Object.fromEntries( (Object.entries(ROLE_DATABASE_KEYS) as Array<[RuntimeRole, string]>).map(([role, key]) => [ key, - `postgresql://${role}.${projectRef}:${encodeURIComponent(passwords[role])}@${poolerHost}:5432/postgres?sslmode=require&uselibpqcompat=true`, + `postgresql://${role}:${encodeURIComponent(passwords[role])}@db.${projectRef}.supabase.co:5432/postgres?sslmode=require&uselibpqcompat=true`, ]), ); } function inferProjectRef(values: Record): string | undefined { + const hostname = databaseUrlPart(values["SUPABASE_GATEWAY_DATABASE_URL"], "hostname"); + const directProjectRef = hostname + ? /^db\.([a-z0-9]{20})\.supabase\.co$/u.exec(hostname)?.[1] + : undefined; + if (directProjectRef) { + return directProjectRef; + } const username = databaseUrlPart(values["SUPABASE_GATEWAY_DATABASE_URL"], "username"); if (!username) { return undefined; @@ -371,10 +359,6 @@ function inferProjectRef(values: Record): string | undefined { return separator === -1 ? undefined : username.slice(separator + 1); } -function inferPoolerHost(values: Record): string | undefined { - return databaseUrlPart(values["SUPABASE_GATEWAY_DATABASE_URL"], "hostname"); -} - function existingRolePassword( values: Record, role: RuntimeRole,