Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/trigger-docs-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Trigger Docs Deploy
on:
push:
branches:
- dev
- main
paths:
- "docs/**"

Expand Down
15 changes: 7 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ npm run typecheck

## 📝 Contributing to Documentation

All documentation content for [codra.run/docs](https://codra.run/docs) lives in `docs/*.mdx` in this repository. Even though the website repo is private, **all documentation is 100% open source** and deploys automatically when PRs are merged into `dev`.
All documentation content for [codra.run/docs](https://codra.run/docs) lives in `docs/*.mdx` in this repository. Even though the website repo is private, **all documentation is 100% open source** and deploys automatically when PRs are merged into `main`.

### MDX Guidelines
- **Files & Routes**: Each file in `docs/*.mdx` maps directly to a route (e.g. `docs/docker.mdx` -> `codra.run/docs/docker`).
Expand All @@ -118,16 +118,15 @@ All documentation content for [codra.run/docs](https://codra.run/docs) lives in

## 🚀 Pull Request Process

Contributions are merged into `dev` first and reach `main` when a release is cut, so `main` always
reflects what is deployed and published. Pull requests opened against `main` will be asked to
retarget to `dev`.
Pull requests are reviewed and merged directly into `main`. There is no separate `dev` integration
branch or release-branch promotion step.

1. **Fork & Branch**: Create a feature branch from `dev`.
1. **Fork & Branch**: Create a feature branch from `main`.
2. **Atomic Commits**: Keep your commits focused and descriptive.
3. **Sync**: Ensure your branch is up to date with `dev`.
4. **Target Branch**: Open pull requests against `dev`.
3. **Sync**: Ensure your branch is up to date with `main`.
4. **Target Branch**: Open pull requests against `main`.
5. **PR Description**: Use the provided template (if available) or clearly explain the _what_ and _why_ of your changes.
6. **CLA Check**: Once you open the PR, an automated check will verify your CLA status. If you haven't signed yet, follow the link in the check output.
6. **Checks & CLA**: Ensure the required CI checks pass. An automated check verifies your CLA status; if you haven't signed yet, follow the link in the check output.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ The full setup and operations guides live at [codra.run/docs](https://codra.run/

## Contributing

Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request against `dev`. Codra uses a Contributor License Agreement for contributions.
Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request against `main`. Codra uses a Contributor License Agreement for contributions.

## License

Expand Down
4 changes: 4 additions & 0 deletions apps/node/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface NodeAppBindings {
LLM_CONFIG_ENCRYPTION_KEY: string;
BOT_USERNAME: string;
ENVIRONMENT: string;
SUBREQUEST_LIMIT?: string | number;
CF_SUBREQUEST_LIMIT?: string | number;
}

export function createNodeEnv(stubs: {
Expand Down Expand Up @@ -59,5 +61,7 @@ export function createNodeEnv(stubs: {
LLM_CONFIG_ENCRYPTION_KEY: requireEnv("LLM_CONFIG_ENCRYPTION_KEY"),
BOT_USERNAME: process.env.BOT_USERNAME || "codra-app",
ENVIRONMENT: process.env.ENVIRONMENT || "development",
SUBREQUEST_LIMIT: process.env.SUBREQUEST_LIMIT,
CF_SUBREQUEST_LIMIT: process.env.CF_SUBREQUEST_LIMIT,
};
}
25 changes: 19 additions & 6 deletions apps/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ dotenv.config({ path: path.resolve(process.cwd(), "../../.env.local") });

import { serve } from "@hono/node-server";
import { createApiRouter } from "@codraoss/api";
import { runWithDb } from "@codraoss/db/client";
import { runWithDb, getDb, type DbClient } from "@codraoss/db/client";
import type { DbEnv } from "@codraoss/db/env";

import {
NodeOrchestrator,
Expand Down Expand Up @@ -60,6 +61,13 @@ env.REVIEW_ORCHESTRATOR = new NodeOrchestrator(
stubs.REVIEW_QUEUE,
);

const dbEnv: DbEnv = {
...env,
HYPERDRIVE: env.DATABASE_CONFIG,
workerMode: false,
};
const dbClient: DbClient = getDb(dbEnv);

const distClientPath = path.resolve(__dirname, "../../../dist/client");
const app = createApiRouter({
serveIndex: async () =>
Expand All @@ -79,8 +87,6 @@ app.use("/*.svg", serveStatic({ root: distClientPath }));
app.use("/*.ico", serveStatic({ root: distClientPath }));
app.get("*", serveStatic({ root: distClientPath, path: "index.html" }));

app.get("/health", (c) => c.text("OK"));

const port = parseInt(process.env.PORT || "3000", 10);

let worker: ReturnType<typeof startWorker> | undefined;
Expand All @@ -105,8 +111,9 @@ if (process.env.START_API !== "false") {

try {
return await runWithDb(
{ ...env, HYPERDRIVE: env.DATABASE_CONFIG },
dbEnv,
() => app.fetch(request, apiEnv as any),
dbClient,
);
} catch (e) {
console.error("SERVE ERROR:", e);
Expand Down Expand Up @@ -136,7 +143,8 @@ if (process.env.START_API !== "false") {
import("@codraoss/models").then(({ ModelRegistryService }) => {
const service = ModelRegistryService.getInstance();
service.refreshFromRemote().catch(() => {});
setInterval(() => service.refreshFromRemote().catch(() => {}), 60 * 60 * 1000).unref();
const timer = setInterval(() => service.refreshFromRemote().catch(() => {}), 60 * 60 * 1000);
(timer as unknown as { unref?: () => void }).unref?.();
}).catch(() => {});

const shutdown = async () => {
Expand All @@ -156,7 +164,12 @@ const shutdown = async () => {
logger.info("Redis client closed.");
}

// Postgres client manages its own pool.
// Close Postgres client pool
if (dbClient?.close) {
logger.info("Closing Postgres client...");
await dbClient.close();
logger.info("Postgres client closed.");
}

logger.info("All services stopped. Exiting.");
};
Expand Down
24 changes: 22 additions & 2 deletions apps/node/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import type { ReviewRuntime } from "@codraoss/core/ports";
import type { DbEnv } from "@codraoss/db/env";
import { TokenTracker } from "@codraoss/core/token-tracker";
import {
TokenTracker,
type TokenTrackerOptions,
} from "@codraoss/core";
import type { NodeAppBindings } from "./env";

export function resolveNodeSubrequestLimits(
env?: Partial<NodeAppBindings>,
): TokenTrackerOptions {
const envLimit =
env?.SUBREQUEST_LIMIT ??
env?.CF_SUBREQUEST_LIMIT ??
process.env.SUBREQUEST_LIMIT ??
process.env.CF_SUBREQUEST_LIMIT;
if (envLimit !== undefined) {
const limit = Number(envLimit);
if (!Number.isNaN(limit) && limit > 0) {
return { maxSubrequests: limit };
}
}
return { maxSubrequests: Number.POSITIVE_INFINITY };
}

import {
makeJobStore,
makeFileReviewStore,
Expand Down Expand Up @@ -52,7 +72,7 @@ export function createReviewRuntime(
},
telemetry: makeTelemetrySink(env),

createTokenTracker: () => new TokenTracker(),
createTokenTracker: () => new TokenTracker(resolveNodeSubrequestLimits(env)),
createGitHub: makeGitHubFactory(env),
createModel: makeModelFactory(env),
createFormatter: makeFormatterFactory(env),
Expand Down
32 changes: 30 additions & 2 deletions apps/worker/src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import type { ReviewRuntime } from "@codraoss/core/ports";
import { TokenTracker } from "@codraoss/core/token-tracker";
import {
TokenTracker,
type TokenTrackerOptions,
DEFAULT_MAX_SUBREQUESTS,
DEFAULT_SUBREQUEST_SAFE_MARGIN,
PAID_WORKER_MAX_SUBREQUESTS,
} from "@codraoss/core";
import type { AppBindings } from "../env";

export function resolveWorkerSubrequestLimits(
env: Partial<AppBindings>,
): TokenTrackerOptions {
if (env.CF_SUBREQUEST_LIMIT !== undefined) {
const limit = Number(env.CF_SUBREQUEST_LIMIT);
if (!Number.isNaN(limit) && limit > 0) {
return { maxSubrequests: limit };
}
}
const isPaid =
env.CF_PAID_WORKER === true ||
env.CF_PAID_WORKER === "true" ||
env.CF_PAID_WORKER === "1";
if (isPaid) {
return { maxSubrequests: PAID_WORKER_MAX_SUBREQUESTS };
}
return {
maxSubrequests: DEFAULT_MAX_SUBREQUESTS,
safeMargin: DEFAULT_SUBREQUEST_SAFE_MARGIN,
};
}
// Imported for its side effect as well as never: this installs the AsyncLocalStorage-backed logger as
// the sink @codraoss/core's logger facade delegates to. Explicit here so engine log lines carry request
// context by construction, rather than because some other module happened to be loaded first.
Expand Down Expand Up @@ -50,7 +78,7 @@ export function createReviewRuntime(env: AppBindings): ReviewRuntime {
repoConfig: makeRepoConfigLoader(env),
telemetry: makeTelemetrySink(env),

createTokenTracker: () => new TokenTracker(),
createTokenTracker: () => new TokenTracker(resolveWorkerSubrequestLimits(env)),
createGitHub: makeGitHubFactory(env),
createModel: makeModelFactory(env),
createFormatter: makeFormatterFactory(env),
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface AppBindings {
ENVIRONMENT: string;
CF_API_TOKEN: string;
CF_ACCOUNT_ID: string;
CF_PAID_WORKER?: string | boolean;
CF_SUBREQUEST_LIMIT?: string | number;
}

export interface AppVariables {
Expand Down
6 changes: 3 additions & 3 deletions docs/contributing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ Contributions are welcome. The recommended workflow keeps changes focused and ea
3. **Install dependencies** — `npm install` and copy `.dev.vars.example` to `.dev.vars` with your secrets filled in.
4. **Make your change** — Keep the scope tight. Prefer existing patterns over new abstractions.
5. **Run typecheck and tests** — `npm run typecheck` and `npm test` must both pass before opening a PR.
6. **Open a pull request** — Open your PR against the `dev` branch. Write a clear description of what changed and why, and reference any related issues.
6. **Open a pull request** — Open your PR against `main`. Write a clear description of what changed and why, and reference any related issues.

<Note type="info">
Before opening a PR against <code>dev</code>, run <code>npm run typecheck</code> and <code>npm test</code>. Both must pass. PRs that break type coverage or existing tests will not be merged.
Pull requests are reviewed and merged directly into <code>main</code>; there is no separate <code>dev</code> integration branch. Run <code>npm run typecheck</code> and <code>npm test</code> before opening a PR, and ensure the required CI checks pass.
</Note>

<h2 id="conventions">Engineering Conventions</h2>
Expand All @@ -51,7 +51,7 @@ Before opening a PR against <code>dev</code>, run <code>npm run typecheck</code>

All documentation content for [codra.run/docs](https://codra.run/docs) is stored in the public `docs/` folder in the Codra repository.

Even though the frontend hosting is managed separately in a private repo, documentation content is 100% open source. When changes are merged into the `dev` branch, an automated GitHub Actions workflow copies the updated MDX files and deploys them to the website.
Even though the frontend hosting is managed separately in a private repo, documentation content is 100% open source. When changes are merged into `main`, an automated GitHub Actions workflow copies the updated MDX files and deploys them to the website.

### Guidelines for Docs
* **Files & Routing**: Stored in `docs/<page>.mdx` mapping directly to `https://codra.run/docs/<page>`.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"setup:cloudflare": "node scripts/setup-cloudflare.js",
"migrate": "node packages/db/scripts/migrate.mjs",
"test": "node scripts/test.mjs",
"test:unit": "node scripts/test.mjs unit",
"test:integration": "node scripts/test.mjs integration",
"test:all": "npm run test --workspaces --if-present",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
Expand Down
18 changes: 17 additions & 1 deletion packages/api/src/create-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,17 @@ export interface PlatformDeps {
llmConfigEncryptionKey: string;
cfApiToken?: string;
cfAccountId?: string;
checkQuota?: ((input: QuotaCheckInput) => Promise<QuotaResult>) | null;
authz?: AuthzPort;
}

import type { ApiRouterDeps, PlatformPort } from "./ports";
import type {
ApiRouterDeps,
PlatformPort,
QuotaCheckInput,
QuotaResult,
AuthzPort,
} from "./ports";
import type {
SessionStore,
ReviewRuntime,
Expand Down Expand Up @@ -81,6 +89,7 @@ import { createOAuthState, consumeOAuthState } from "./platform/oauth";
import { verifyGitHubWebhookSignature } from "@codraoss/core/verify";

import { createModelRunnerDeps } from "./model-runner-deps";
import { createDefaultQuotaChecker } from "./platform/quota";

function toAppBindingsConfig(p: PlatformDeps) {
return {
Expand Down Expand Up @@ -184,5 +193,12 @@ export function createSharedApiDeps(p: PlatformDeps): ApiRouterDeps {
normalizeGitHubWebhook(eventName, payload),
extractReviewRequest: (input: any) => extractReviewRequest(input),
},
checkQuota:
p.checkQuota !== undefined
? (p.checkQuota ?? undefined)
: p.kv
? createDefaultQuotaChecker(p.kv)
: undefined,
authz: p.authz,
};
}
1 change: 1 addition & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export type {
export { requirePermission, requireQuota } from "./middleware/authorize";
export { requireSession } from "./middleware/auth";
export { requireCsrfHeader } from "./middleware/csrf";
export { createDefaultQuotaChecker } from "./platform/quota";

export * from "./create-deps";
1 change: 1 addition & 0 deletions packages/api/src/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./kv";
export * from "./config";
export * from "./oauth";
export * from "./updates-email";
export * from "./quota";
65 changes: 65 additions & 0 deletions packages/api/src/platform/quota.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { QuotaCheckInput, QuotaResult } from "../ports";
import type { KvCompat } from "./kv";

export interface DefaultQuotaOptions {
windowSeconds?: number;
maxReviewsPerWindow?: number;
maxRetriesPerWindow?: number;
}

export const DEFAULT_QUOTA_WINDOW_SECONDS = 600; // 10 minutes
export const DEFAULT_MAX_REVIEWS_PER_WINDOW = 30; // 30 review requests per 10 minutes per repo/installation
export const DEFAULT_MAX_RETRIES_PER_WINDOW = 20; // 20 retries per 10 minutes per user/action

export function createDefaultQuotaChecker(
kv: KvCompat,
options: DefaultQuotaOptions = {},
): (input: QuotaCheckInput) => Promise<QuotaResult> {
const windowSeconds = options.windowSeconds ?? DEFAULT_QUOTA_WINDOW_SECONDS;
const maxReviews = options.maxReviewsPerWindow ?? DEFAULT_MAX_REVIEWS_PER_WINDOW;
const maxRetries = options.maxRetriesPerWindow ?? DEFAULT_MAX_RETRIES_PER_WINDOW;

return async (input: QuotaCheckInput): Promise<QuotaResult> => {
let limit = maxReviews;
if (input.action === "jobs.retry" || input.action === "jobs.rerun") {
limit = maxRetries;
}

const subjectKey =
input.subject?.installationId
? `install:${input.subject.installationId}`
: input.subject?.owner && input.subject?.repo
? `repo:${input.subject.owner}/${input.subject.repo}`
: input.user?.login
? `user:${input.user.login}`
: "global";

const currentTimestamp = Math.floor(Date.now() / 1000);
const windowSlot = Math.floor(currentTimestamp / windowSeconds);
const key = `quota:${input.action}:${subjectKey}:${windowSlot}`;

try {
const raw = await kv.get(key, "text");
const count = raw ? parseInt(raw, 10) : 0;
if (Number.isFinite(count) && count >= limit) {
const retryAfterSeconds = Math.max(
1,
(windowSlot + 1) * windowSeconds - currentTimestamp,
);
return {
allowed: false,
retryAfterSeconds,
reason: `Quota exceeded for ${input.action}. Limit is ${limit} per ${windowSeconds}s.`,
};
}

await kv.put(key, String(count + 1), {
expirationTtl: windowSeconds * 2,
});
return { allowed: true };
} catch {
// In case of KV lookup error, fail open to avoid blocking operations
return { allowed: true };
}
};
}
3 changes: 3 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export const MAX_FINALIZE_CONTINUATIONS = 3;
export const MAX_ATTEMPTS_PER_WORKFLOW_INSTANCE = 6;

// Budget & Attempts
export const DEFAULT_MAX_SUBREQUESTS = 50;
export const DEFAULT_SUBREQUEST_SAFE_MARGIN = 25;
export const PAID_WORKER_MAX_SUBREQUESTS = 1000;
export const FILE_FIXED_SUBREQUESTS = 2;
export const MAX_MODEL_ATTEMPTS_ESTIMATE = 4;
export const MISSING_FILE_ERROR =
Expand Down
Loading
Loading