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
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ jobs:
- name: Static Analysis (Typecheck)
run: npm run typecheck && npm run typecheck:all

- name: Boundary Check (@codra/core purity)
run: npm run check:boundaries

- name: Static Analysis (Lint)
run: npm run lint
Expand All @@ -79,4 +77,4 @@ jobs:
run: npx vite build

- name: Build (worker bundle, dry run)
run: npx wrangler deploy --dry-run --outdir=.wrangler/dry
run: cd apps/worker && npx wrangler deploy --dry-run --outdir=.wrangler/dry
13 changes: 12 additions & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"name": "@codra/worker",
"version": "0.9.4",
"private": true
"private": true,
"type": "module",
"dependencies": {
"@codra/core": "*",
"@codra/db": "*",
"@codra/schema": "*",
"hono": "^4.12.25"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0",
"wrangler": "^4.114.0"
}
}
53 changes: 53 additions & 0 deletions apps/worker/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ReviewJobMessage } from '@codra/schema';
import type { DashboardSessionUser, SessionStore } from '@codra/core';

export interface WorkersAiBinding {
run(model: string, input: Record<string, unknown>, options?: { signal?: AbortSignal }): Promise<any>;
}

export interface QueueProducer<T> {
send(message: T, options?: { delaySeconds?: number }): Promise<void>;
}

export interface AssetsBinding {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}

export interface HyperdriveBinding {
connectionString: string;
}

export interface AppBindings {
SESSION_STORE: SessionStore;
AI: WorkersAiBinding;
APP_KV: KVNamespace;
REVIEW_QUEUE: QueueProducer<ReviewJobMessage>;
REVIEW_WORKFLOW: Workflow;
ASSETS: AssetsBinding;
HYPERDRIVE: HyperdriveBinding;
APP_PRIVATE_KEY: string;
GITHUB_APP_ID: string;
GITHUB_APP_SLUG?: string;
GITHUB_APP_WEBHOOK_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
AUTH_CALLBACK_URL: string;
APP_URL: string;
DASHBOARD_ALLOWED_USERS: string;
LLM_CONFIG_ENCRYPTION_KEY: string;
BOT_USERNAME: string;
ENVIRONMENT: string;
CF_API_TOKEN: string;
CF_ACCOUNT_ID: string;
}

export interface AppVariables {
sessionToken: string | null;
sessionUser: DashboardSessionUser | null;
requestId: string;
}

export type AppEnv = {
Bindings: AppBindings;
Variables: AppVariables;
};
13 changes: 9 additions & 4 deletions src/server/index.ts → apps/worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { createApp } from './app';
import { createApp } from '../../../src/server/app';
import { ReviewWorkflow } from './workflows/review';
import type { AppBindings } from './env';
import { reviewJobMessageSchema } from '@codra/schema';
import { logger } from '@server/core/logger';
import { disposeRpc } from '@server/core/rpc';
import { runWithDb } from '@server/db/client';
import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@server/db/jobs';
import { runWithDb } from '@codra/db/client';
import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@codra/db/jobs';
import { runBestEffortJobMaintenance } from '@server/core/job-recovery';

import { CloudflareSessionStore } from './sessions';
const app = createApp();

export { ReviewWorkflow };

export default {
fetch(request: Request, env: AppBindings, ctx: ExecutionContext) {
return runWithDb(env, () => app.fetch(request, env, ctx));
const apiEnv = {
...env,
SESSION_STORE: new CloudflareSessionStore(env.APP_KV),
};
return runWithDb(env, () => app.fetch(request, apiEnv as any, ctx));
},

async scheduled(_controller: ScheduledController, env: AppBindings, _ctx: ExecutionContext) {
Expand Down
20 changes: 20 additions & 0 deletions apps/worker/src/ports/cloudflare-kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { KeyValueStore } from '@codra/core';

export class CloudflareKV implements KeyValueStore {
constructor(private readonly kv: KVNamespace) {}

async put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void> {
await this.kv.put(key, value, options);
}

async get(key: string, type: 'json' | 'text'): Promise<any> {
if (type === 'json') {
return this.kv.get(key, 'json');
}
return this.kv.get(key, 'text');
}

async delete(key: string): Promise<void> {
await this.kv.delete(key);
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';
import type { AppBindings } from '@server/env';
import { runReviewJob, FRESH_INVOCATION_YIELD_SECONDS } from '@server/core/review';
import { type ReviewJobMessage } from '@codra/schema';
import { setJobWorkflowInstance } from '@server/db/jobs';
import { logger } from '@server/core/logger';
import type { JobOrchestrator } from '@codra/core';
import type { ReviewJobMessage } from '@codra/schema';
import { FRESH_INVOCATION_YIELD_SECONDS } from '@codra/core';
import { runReviewJob } from '@server/core/review';
import { setJobWorkflowInstance } from '@codra/db/jobs';
import { logger } from '@codra/core/logger';
import { runBestEffortJobMaintenance } from '@server/core/job-recovery';
import { runWithDb } from '@server/db/client';
import type { AppBindings } from '../env';
import type { WorkflowStep } from 'cloudflare:workers';

export class ReviewWorkflow extends WorkflowEntrypoint<AppBindings, ReviewJobMessage> {
async run(event: WorkflowEvent<ReviewJobMessage>, step: WorkflowStep) {
// One DB client for the whole invocation, instead of a Hyperdrive connection per query; a replay after step.sleep just runs this again for the new invocation.
return runWithDb(this.env, () => this.execute(event, step));
export class CloudflareOrchestrator implements JobOrchestrator {
constructor(private readonly workflow: Workflow, private readonly env?: AppBindings) {}

async startReviewJob(id: string, params: ReviewJobMessage): Promise<void> {
await this.workflow.create({
id,
params,
});
}

private async execute(event: WorkflowEvent<ReviewJobMessage>, step: WorkflowStep) {
async executeSteps(event: { payload: ReviewJobMessage, instanceId: string }, step: WorkflowStep) {
if (!this.env) throw new Error('env is required for execution');
const params = event.payload;
const env = this.env;

const jobId = params.jobId ?? params.deliveryId;

await step.do('bind-workflow-id', async () => {
Expand Down Expand Up @@ -73,18 +78,15 @@ export class ReviewWorkflow extends WorkflowEntrypoint<AppBindings, ReviewJobMes
filesReviewed: 0,
verdict: 'failed',
severityDistribution: {},
// concurrencyLevel is not available in the workflow context (no DB access at this level)
concurrencyLevel: 'unknown',
prTotalLinesChanged: 0,
// attempt is 1-indexed; subtract 1 so the first run is retryCount=0
retryCount: Math.max(0, attempt - 1),
});
});
throw error;
}

if (result.action === 'next_phase') {
// Hand the next phase to a BRAND-NEW instance when this one can no longer get a clean subrequest budget: a long-lived instance stops hibernating between steps, so its budget never resets.
if (result.freshInstance) {
const nextJobId = result.jobId ?? jobId;
if (nextJobId) {
Expand All @@ -101,17 +103,14 @@ export class ReviewWorkflow extends WorkflowEntrypoint<AppBindings, ReviewJobMes
}
}
phase = result.phase;
// Floor at FRESH_INVOCATION_YIELD_SECONDS, not 1: a short sleep does NOT hibernate, so the next phase would run on a spent budget while its TokenTracker starts at zero.
delaySeconds = Math.max(result.delaySeconds ?? 0, FRESH_INVOCATION_YIELD_SECONDS);
} else if (result.action === 'retry') {
delaySeconds = result.delaySeconds ?? 60;
} else {
// 'ack' or completion
break;
}
}

// Yield first, so maintenance gets its own subrequest budget rather than the remains of the one the final phase step may have just exhausted.
await step.sleep('pre-post-maintenance-yield', `${FRESH_INVOCATION_YIELD_SECONDS} seconds`);

try {
Expand Down
25 changes: 25 additions & 0 deletions apps/worker/src/sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { DashboardSessionUser, SessionStore } from '@codra/core';

export class CloudflareSessionStore implements SessionStore {
constructor(private readonly kv: KVNamespace) {}

private sessionKey(token: string) {
return `session:${token}`;
}

async createSession(session: DashboardSessionUser): Promise<string> {
const token = crypto.randomUUID();
await this.kv.put(this.sessionKey(token), JSON.stringify(session), {
expirationTtl: 60 * 60 * 24 * 7,
});
return token;
}

async readSession(token: string): Promise<DashboardSessionUser | null> {
return this.kv.get(this.sessionKey(token), 'json');
}

async destroySession(token: string): Promise<void> {
await this.kv.delete(this.sessionKey(token));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 76d8ec86de20e8aacd6c22180ec1b53d)
// Generated by Wrangler by running `wrangler types ./src/worker-env.d.ts` (hash: 76d8ec86de20e8aacd6c22180ec1b53d)
// Runtime types generated with workerd@1.20260801.1 2026-04-16 nodejs_compat
interface __BaseEnv_Env {
APP_KV: KVNamespace;
Expand Down
17 changes: 17 additions & 0 deletions apps/worker/src/workflows/review.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';
import type { AppBindings } from '../env';
import { type ReviewJobMessage } from '@codra/schema';
import { runWithDb } from '@codra/db/client';
import { CloudflareOrchestrator } from '../ports/cloudflare-orchestrator';

export class ReviewWorkflow extends WorkflowEntrypoint<AppBindings, ReviewJobMessage> {
async run(event: WorkflowEvent<ReviewJobMessage>, step: WorkflowStep) {
// One DB client for the whole invocation, instead of a Hyperdrive connection per query; a replay after step.sleep just runs this again for the new invocation.
return runWithDb(this.env, () => this.execute(event, step));
}

private async execute(event: WorkflowEvent<ReviewJobMessage>, step: WorkflowStep) {
const orchestrator = new CloudflareOrchestrator(this.env.REVIEW_WORKFLOW, this.env);
await orchestrator.executeSteps(event, step);
}
}
File renamed without changes.
4 changes: 2 additions & 2 deletions wrangler.jsonc → apps/worker/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "codra",
"main": "./src/server/index.ts",
"main": "./src/index.ts",
"compatibility_date": "2026-04-16",
"compatibility_flags": [
"nodejs_compat"
Expand Down Expand Up @@ -71,7 +71,7 @@
]
},
"assets": {
"directory": "./dist/client",
"directory": "../../dist/client",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": [
Expand Down
Loading