Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,49 @@ The enforced rules live in the root `biome.jsonc` and TypeScript configs.
- Functions/variables: `camelCase`. Booleans: `is/has/can/should` prefix.
- Constants: `UPPER_SNAKE_CASE` only for true module-level immutables.

## House conventions

Use these names as contracts, not interchangeable synonyms:

- `fetch*` performs third-party network I/O.
- `load*` assembles a composite from our database.
- `read*` reads bytes or Durable Object storage.
- `get*` is a cheap keyed lookup without a fallback chain.
- `resolve*` selects through an explicit fallback chain.
- `assert*` synchronously checks an invariant and throws.
- `require*` asynchronously fetches a prerequisite and throws when absent.
- `ensure*` idempotently creates or establishes a prerequisite.
- `guard*` wraps an operation with policy.
- `verify*` is cryptographic verification; `validate*` applies business rules.

Data and lifecycle nouns have fixed meanings:

- `Row` is a private raw persistence shape and keeps source `snake_case`.
- `Record` is an exported, mapped persistence shape in `camelCase`.
- `Options` are caller-selected, per-call inputs.
- `Config` is validated, longer-lived configuration.
- `Context` carries request-scoped dependencies and identity.
- `State` is mutable or persisted lifecycle data.

Wire and schema grammar is equally strict:

- Generic wire or storage union tags use `type`.
- Generic in-process-only union tags use `kind`.
- Domain fields such as `status`, `provider`, and literal `ok` keep their names.
- Persisted message parts are wire data and therefore keep `type`.
- Schemas we own are closed with `z.strictObject(...)`.
- Third-party payload projections use `z.object(...).strip()` intentionally.
- Use `z.looseObject(...)` only when unknown keys must be preserved by contract.
- Error codes use `<category>_<subject>_<condition>`.
- Event names use `<subsystem>_<object>_<past-participle>`.

File suffixes describe architectural roles:

- Shared implementation modules end in `-support`, not `-utils` or `-helpers`.
- UI role modules use `-controller`, `-view`, or `-model`.
- Worker route collections use `-routes`; transport-only collections use `-http-routes`.
- Hooks keep a `use*` function name, but controller filenames omit a redundant `use-` prefix.

## Commit messages

Conventional commits required (enforced by commitlint via Lefthook `commit-msg` hook):
Expand Down
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,49 @@ product QA. Code the all-weeks V2 surface first, then perform final QA through
the direct browser commands and direct console/network/app-log inspection. The
removed V1 tree must not be restored or copied back as a testing surface.

## House conventions

Use these names as contracts, not interchangeable synonyms:

- `fetch*` performs third-party network I/O.
- `load*` assembles a composite from our database.
- `read*` reads bytes or Durable Object storage.
- `get*` is a cheap keyed lookup without a fallback chain.
- `resolve*` selects through an explicit fallback chain.
- `assert*` synchronously checks an invariant and throws.
- `require*` asynchronously fetches a prerequisite and throws when absent.
- `ensure*` idempotently creates or establishes a prerequisite.
- `guard*` wraps an operation with policy.
- `verify*` is cryptographic verification; `validate*` applies business rules.

Data and lifecycle nouns have fixed meanings:

- `Row` is a private raw persistence shape and keeps source `snake_case`.
- `Record` is an exported, mapped persistence shape in `camelCase`.
- `Options` are caller-selected, per-call inputs.
- `Config` is validated, longer-lived configuration.
- `Context` carries request-scoped dependencies and identity.
- `State` is mutable or persisted lifecycle data.

Wire and schema grammar is equally strict:

- Generic wire or storage union tags use `type`.
- Generic in-process-only union tags use `kind`.
- Domain fields such as `status`, `provider`, and literal `ok` keep their names.
- Persisted message parts are wire data and therefore keep `type`.
- Schemas we own are closed with `z.strictObject(...)`.
- Third-party payload projections use `z.object(...).strip()` intentionally.
- Use `z.looseObject(...)` only when unknown keys must be preserved by contract.
- Error codes use `<category>_<subject>_<condition>`.
- Event names use `<subsystem>_<object>_<past-participle>`.

File suffixes describe architectural roles:

- Shared implementation modules end in `-support`, not `-utils` or `-helpers`.
- UI role modules use `-controller`, `-view`, or `-model`.
- Worker route collections use `-routes`; transport-only collections use `-http-routes`.
- Hooks keep a `use*` function name, but controller filenames omit a redundant `use-` prefix.

## Where things live

| Need | File |
Expand Down
40 changes: 21 additions & 19 deletions apps/agent-worker/src/agent-api-run-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
import { APIError, readJsonRequest } from "@cheatcode/observability";
import {
type AgentRunId,
ThreadId,
UserId as toUserId,
toThreadId,
toUserId,
type UIMessagePart,
type UserId,
} from "@cheatcode/types";
Expand Down Expand Up @@ -60,11 +60,11 @@ type AgentContext = Context<{ Bindings: AgentEnv }>;
type CreateRunResult = Awaited<ReturnType<typeof createAgentRunForThread>>;
type ExistingRunResult = Extract<
CreateRunResult,
{ type: "active-run-exists" | "idempotent-replay" }
{ kind: "active-run-exists" | "idempotent-replay" }
>;
type RejectedRunResult = Exclude<
CreateRunResult,
ExistingRunResult | Extract<CreateRunResult, { type: "created" }>
ExistingRunResult | Extract<CreateRunResult, { kind: "created" }>
>;

export function registerAgentRunHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void {
Expand Down Expand Up @@ -100,11 +100,13 @@ async function createRun(c: AgentContext): Promise<Response> {
return withUserDb(c.env, parsedUserId, async ({ transaction }) => {
const prepared = await transaction(async (tx) => {
const thread = await loadAgentRunThreadContext(tx, {
threadId: ThreadId(threadId),
threadId: toThreadId(threadId),
userId: parsedUserId,
});
if (!thread) {
throw new APIError(404, "not_found_thread", "Thread not found", { retriable: false });
throw new APIError(404, "resource_thread_not_found", "Thread not found", {
retriable: false,
});
}
return {
entitlement: await loadRunEntitlementPolicy(tx, parsedUserId),
Expand All @@ -125,18 +127,18 @@ async function createRun(c: AgentContext): Promise<Response> {
userId: parsedUserId,
}),
);
if (result.type === "created") {
if (result.kind === "created") {
const outcome = await startAgentRun(c.env, {
body,
modelExplicit: result.modelExplicit,
isModelExplicit: result.isModelExplicit,
personalization: prepared.personalization,
run: result.run,
sandboxName,
userId,
});
return resolveRunAdmission(parsedUserId, result.run, outcome, transaction);
}
if (result.type === "active-run-exists" || result.type === "idempotent-replay") {
if (result.kind === "active-run-exists" || result.kind === "idempotent-replay") {
const outcome = await reconcileAgentRunAdmission(c.env, userId, result.run.runId);
return resolveRunAdmission(parsedUserId, result.run, outcome, transaction);
}
Expand All @@ -161,13 +163,13 @@ async function persistRunRequest(
idempotencyKeyHash: input.requestIdentity.keyHash,
personalization: input.personalization,
requestBodyHash: input.requestIdentity.bodyHash,
threadId: ThreadId(input.threadId),
threadId: toThreadId(input.threadId),
userId: input.userId,
...(input.body.model === undefined ? {} : { modelId: input.body.model }),
},
thread,
);
if (created.type === "created") {
if (created.kind === "created") {
await createThreadMessage(tx, {
agentRunId: created.run.runId,
parts: persistedUserMessageParts(input.body),
Expand All @@ -193,16 +195,16 @@ function persistedUserMessageParts(body: CreateRun): UIMessagePart[] {
}

function rejectedRunError(result: RejectedRunResult): APIError {
if (result.type === "thread-not-found") {
return new APIError(404, "not_found_thread", "Thread not found", { retriable: false });
if (result.kind === "thread-not-found") {
return new APIError(404, "resource_thread_not_found", "Thread not found", { retriable: false });
}
if (result.type === "idempotency-key-reused") {
if (result.kind === "idempotency-key-reused") {
return new APIError(422, "idempotency_key_reused", "Idempotency key was reused", {
hint: "Generate a new Idempotency-Key for a different thread or request body.",
retriable: false,
});
}
if (result.type === "project-read-only") {
if (result.kind === "project-read-only") {
return new APIError(403, "permission_plan_required", "Project is read-only after downgrade", {
details: { archiveAfter: result.archiveAfter?.toISOString() ?? null },
hint: "Delete or archive over-limit projects, or upgrade your plan to continue editing this project.",
Expand All @@ -222,10 +224,10 @@ async function resolveRunAdmission(
outcome: AgentRunAdmissionOutcome,
transaction: UserDatabaseSession["transaction"],
): Promise<Response> {
if (outcome.type === "confirmed") {
if (outcome.kind === "confirmed") {
return withRunLocation(outcome.response, run.runId);
}
if (outcome.type === "ambiguous") {
if (outcome.kind === "ambiguous") {
throw runAdmissionAmbiguousError(run.runId);
}
const reconciliation = await reconcileAbsentRunRow(transaction, userId, run.runId);
Expand All @@ -252,7 +254,7 @@ function runAdmissionAbsentError(runId: AgentRunId): APIError {
}

function runAdmissionAmbiguousError(runId: AgentRunId): APIError {
return new APIError(503, "unavailable_maintenance", "Agent run admission is uncertain", {
return new APIError(503, "service_maintenance_unavailable", "Agent run admission is uncertain", {
details: { runId },
hint: "Retry this same request so Cheatcode can reconcile the existing run safely.",
retriable: true,
Expand Down Expand Up @@ -327,7 +329,7 @@ function readRunRequestIdentity(headers: Headers): { bodyHash: string; keyHash:
keyHash: headers.get(RUN_IDEMPOTENCY_KEY_HASH_HEADER),
});
if (!parsed.success) {
throw new APIError(400, "invalid_request_body", "Missing internal run request identity", {
throw new APIError(400, "request_body_invalid", "Missing internal run request identity", {
retriable: false,
});
}
Expand Down
62 changes: 43 additions & 19 deletions apps/agent-worker/src/agent-api-system-routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { findGeneratedOutput, getProject, withUserDb } from "@cheatcode/db";
import { previewHostnameForWorker, resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env";
import { APIError } from "@cheatcode/observability";
import { OutputIdSchema, ProjectId, UserId } from "@cheatcode/types";
import {
OutputIdSchema,
type ProjectId,
toProjectId,
toUserId,
type UserId,
} from "@cheatcode/types";
import {
AGENT_FORWARD_ROUTES,
InternalAgentStateDeleteBodySchema,
Expand Down Expand Up @@ -97,10 +103,15 @@ async function deleteRunStates(env: AgentEnv, userId: string, runIds: string[]):
if (!response.ok) {
const status = response.status;
await response.body?.cancel().catch(() => undefined);
throw new APIError(503, "unavailable_maintenance", "Run durable state deletion failed", {
details: { status },
retriable: true,
});
throw new APIError(
503,
"service_maintenance_unavailable",
"Run durable state deletion failed",
{
details: { status },
retriable: true,
},
);
}
await response.body?.cancel().catch(() => undefined);
}
Expand All @@ -116,10 +127,12 @@ function deletedStateResult(): InternalStateDeleteResponse {

async function mintOutputDownloadUrl(c: AgentContext): Promise<Response> {
const outputId = parseOutputId(c.req.param("outputId"));
const userId = UserId(readGatewayUserId(c.req.raw.headers));
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, "not_found_output", "Output object not found", { retriable: false });
throw new APIError(404, "resource_output_not_found", "Output object not found", {
retriable: false,
});
}
const capability = await createOutputDownloadCapability({
baseUrl: outputDownloadBaseUrl(c.env),
Expand All @@ -144,14 +157,16 @@ async function downloadOutput(c: AgentContext): Promise<Response> {
userId: query.userId,
});
if (!isValid) {
throw new APIError(403, "permission_denied", "Invalid or expired output download URL", {
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);
if (!object?.body) {
throw new APIError(404, "not_found_output", "Output object not found", { retriable: false });
throw new APIError(404, "resource_output_not_found", "Output object not found", {
retriable: false,
});
}
return new Response(object.body, {
headers: {
Expand All @@ -167,7 +182,7 @@ async function downloadOutput(c: AgentContext): Promise<Response> {
function parseOutputId(value: string | undefined): string {
const parsed = OutputIdSchema.safeParse(value);
if (!parsed.success) {
throw new APIError(400, "invalid_path_param", "Invalid output id", {
throw new APIError(400, "request_path_param_invalid", "Invalid output id", {
details: { issues: parsed.error.issues.map((issue) => issue.message) },
retriable: false,
});
Expand All @@ -182,7 +197,7 @@ function parseOutputDownloadQuery(c: AgentContext): z.infer<typeof OutputDownloa
userId: c.req.query("userId"),
});
if (!parsed.success) {
throw new APIError(400, "invalid_query_param", "Invalid output download signature", {
throw new APIError(400, "request_query_param_invalid", "Invalid output download signature", {
details: { issues: parsed.error.issues.map((issue) => issue.message) },
retriable: false,
});
Expand All @@ -194,7 +209,9 @@ async function findDownloadableOutput(env: AgentEnv, outputId: string, userId: U
return withUserDb(env, userId, async ({ transaction }) => {
const output = await transaction((tx) => findGeneratedOutput(tx, { outputId, userId }));
if (!output) {
throw new APIError(404, "not_found_output", "Output not found", { retriable: false });
throw new APIError(404, "resource_output_not_found", "Output not found", {
retriable: false,
});
}
return output;
});
Expand All @@ -204,9 +221,14 @@ async function resolveOutputSigningSecret(secret: WorkerSecret): Promise<string
try {
return await resolveWorkerSecret(secret);
} catch {
throw new APIError(503, "unavailable_maintenance", "Output signing secret is unavailable", {
retriable: true,
});
throw new APIError(
503,
"service_maintenance_unavailable",
"Output signing secret is unavailable",
{
retriable: true,
},
);
}
}

Expand All @@ -221,15 +243,17 @@ function outputDownloadBaseUrl(env: AgentEnv): string | undefined {
async function downloadProjectArchive(c: AgentContext): Promise<Response> {
const parsedProjectId = z.string().uuid().safeParse(c.req.param("projectId"));
if (!parsedProjectId.success) {
throw new APIError(400, "invalid_path_param", "Invalid project id", {
throw new APIError(400, "request_path_param_invalid", "Invalid project id", {
details: { issues: parsedProjectId.error.issues.map((issue) => issue.message) },
retriable: false,
});
}
const userId = UserId(readGatewayUserId(c.req.raw.headers));
const project = await loadProject(c.env, userId, ProjectId(parsedProjectId.data));
const userId = toUserId(readGatewayUserId(c.req.raw.headers));
const project = await loadProject(c.env, userId, toProjectId(parsedProjectId.data));
if (!project) {
throw new APIError(404, "not_found_project", "Project not found", { retriable: false });
throw new APIError(404, "resource_project_not_found", "Project not found", {
retriable: false,
});
}
const sandbox = await sandboxForUser(c.env, userId);
const archive = await sandbox.downloadProjectArchive({ workspaceSlug: project.workspaceSlug });
Expand Down
10 changes: 4 additions & 6 deletions apps/agent-worker/src/agent-lifecycle-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ import { z } from "zod";
import { deleteAgentUserState } from "./agent-api-system-routes";
import type { AgentEnv } from "./agent-env";

const AgentLifecycleCallerSchema = z
.object({
caller: z.literal("webhooks"),
capability: z.literal("agent-lifecycle"),
})
.strict();
const AgentLifecycleCallerSchema = z.strictObject({
caller: z.literal("webhooks"),
capability: z.literal("agent-lifecycle"),
});

type AgentLifecycleCaller = z.infer<typeof AgentLifecycleCallerSchema>;

Expand Down
Loading