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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
33 changes: 33 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changesets

This folder is managed by [Changesets](https://github.com/changesets/changesets), used here only for
**local, manual** versioning of the public `@codraoss/*` packages. There is no CI-based publishing.

The seven publishable packages (`schema`, `core`, `db`, `models`, `provider-github`, `api`, `ui`)
are a **fixed group** — they version and release in lockstep (currently `0.9.4`). The worker app
(`@codraoss/worker`) is private and never published.

## Recording a change

```bash
npx changeset # pick the bump, write a summary; commit the generated file
```

## Cutting a release (run locally, then publish by hand)

```bash
npm run version:packages # applies pending changesets: bumps all @codraoss/* in lockstep + changelog
npm run release # builds dist and runs `changeset publish` for you
```

`npm run release` builds every package to `dist/` and publishes. Publishing is also possible per
package with plain npm — the `prepack` hook rewrites `exports` to the compiled `dist/` paths in the
tarball automatically:

```bash
npm run build:packages
npm publish -w @codraoss/schema # ...repeat bottom-up: schema → core → db/models/provider-github → api → ui
```

You must be logged in to npm (`npm whoami`) and own the `@codraoss` scope. First publish of each
package needs public access, which `publishConfig.access` already sets.
21 changes: 21 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@codraoss/schema",
"@codraoss/core",
"@codraoss/db",
"@codraoss/models",
"@codraoss/provider-github",
"@codraoss/api",
"@codraoss/ui"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@codraoss/worker"]
}
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Build packages
run: npm run build:packages

- name: Validate package exports
run: |
npm run check:exports
for dir in packages/*/; do
echo "publint $dir"
(cd "$dir" && npx --no-install publint) || exit 1
done

- name: Static Analysis (Typecheck)
run: npm run typecheck && npm run typecheck:all

Expand Down
76 changes: 59 additions & 17 deletions .github/workflows/cla-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,40 +15,82 @@ jobs:
contents: read
pull-requests: read
steps:
- name: Verify contributor CLA signature
- name: Verify CLA signatures for the PR author and every commit author
env:
CLA_CHECK_SECRET: ${{ secrets.CLA_CHECK_SECRET }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
node -e "
(async () => {
const author = process.env.PR_AUTHOR;
const secret = process.env.CLA_CHECK_SECRET;

if (!author) throw new Error('Missing PR author.');
const token = process.env.GITHUB_TOKEN;
const repo = process.env.REPO;
const prNumber = process.env.PR_NUMBER;
const prAuthor = process.env.PR_AUTHOR;

if (!secret) throw new Error('Missing CLA_CHECK_SECRET.');
if (!token) throw new Error('Missing GITHUB_TOKEN.');
if (!repo || !prNumber || !prAuthor) throw new Error('Missing PR context.');

const isBot = (login) => login.endsWith('[bot]');
const logins = new Set();
if (!isBot(prAuthor)) logins.add(prAuthor);

console.log('Checking CLA signature for @' + author + '...');
// Walk every commit in the PR, not just the opener: cherry-picked or
// applied patches carry other people's copyright and need a signature too.
const headers = {
authorization: 'Bearer ' + token,
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
};
const unmatched = [];
for (let page = 1; page <= 3; page++) {
const url = 'https://api.github.com/repos/' + repo + '/pulls/' + prNumber + '/commits?per_page=100&page=' + page;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error('GitHub API error ' + res.status + ': ' + (await res.text()));
const commits = await res.json();
for (const c of commits) {
if (c.author && c.author.login) {
if (c.author.type !== 'Bot' && !isBot(c.author.login)) logins.add(c.author.login);
} else {
unmatched.push(c.sha.slice(0, 7) + ' (' + c.commit.author.name + ' <' + c.commit.author.email + '>)');
}
}
if (commits.length < 100) break;
}

const response = await fetch('https://codra.run/api/internal/check-cla?user=' + encodeURIComponent(author), {
headers: {
'x-cla-check-secret': secret,
},
});
if (unmatched.length > 0) {
console.error('❌ Some commits are not linked to a GitHub account, so their CLA status cannot be verified:');
for (const line of unmatched) console.error(' ' + line);
console.error('Each author must add their commit email to their GitHub account, or the commits must be re-authored.');
process.exit(1);
}

if (!response.ok) {
const body = await response.text();
throw new Error('API Error ' + response.status + ': ' + body);
const unsigned = [];
for (const login of [...logins].sort()) {
console.log('Checking CLA signature for @' + login + '...');
const res = await fetch('https://codra.run/api/internal/check-cla?user=' + encodeURIComponent(login), {
headers: { 'x-cla-check-secret': secret },
});
if (!res.ok) throw new Error('API Error ' + res.status + ': ' + (await res.text()));
const payload = await res.json();
if (payload.signed) {
console.log('✅ CLA confirmed for @' + login + '.');
} else {
unsigned.push(login);
}
}

const payload = await response.json();
if (!payload.signed) {
console.error('❌ @' + author + ' has not signed the CLA.');
if (unsigned.length > 0) {
for (const login of unsigned) console.error('❌ @' + login + ' has not signed the CLA.');
console.error('Please visit https://codra.run/cla to sign.');
process.exit(1);
}

console.log('✅ CLA confirmed for @' + author + '.');
console.log('✅ CLA confirmed for all ' + logins.size + ' author(s).');
})().catch(err => {
console.error(err.message);
process.exit(1);
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,7 @@ vite.config.ts.timestamp-*

.agent

*.prepack-bak
tsup.config.bundled_*.mjs
*.tgz
.npmrc
11 changes: 7 additions & 4 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
{
"name": "@codra/worker",
"name": "@codraoss/worker",
"version": "0.9.4",
"private": true,
"type": "module",
"dependencies": {
"@codra/core": "*",
"@codra/db": "*",
"@codra/schema": "*",
"@codraoss/api": "*",
"@codraoss/core": "*",
"@codraoss/db": "*",
"@codraoss/models": "*",
"@codraoss/provider-github": "*",
"@codraoss/schema": "*",
"hono": "^4.12.25"
},
"devDependencies": {
Expand Down
44 changes: 26 additions & 18 deletions apps/worker/src/api-deps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { ApiRouterDeps } from '@codra/api';
import type { ApiRouterDeps } from '@codraoss/api';
import type { AppBindings } from './env';

import * as dbAccounts from '@codra/db/accounts';
import * as dbJobs from '@codra/db/jobs';
import * as dbFileReviews from '@codra/db/file-reviews';
import * as dbCommentFeedback from '@codra/db/comment-feedback';
import * as dbModelConfigs from '@codra/db/model-configs';
import * as dbRepoConfigs from '@codra/db/repo-configs';
import * as dbAppSettings from '@codra/db/app-settings';
import * as dbStats from '@codra/db/stats';
import * as dbWebhookDeliveries from '@codra/db/webhook-deliveries';
import * as dbAccounts from '@codraoss/db/accounts';
import * as dbJobs from '@codraoss/db/jobs';
import * as dbFileReviews from '@codraoss/db/file-reviews';
import * as dbCommentFeedback from '@codraoss/db/comment-feedback';
import * as dbModelConfigs from '@codraoss/db/model-configs';
import * as dbRepoConfigs from '@codraoss/db/repo-configs';
import * as dbAppSettings from '@codraoss/db/app-settings';
import * as dbStats from '@codraoss/db/stats';
import * as dbWebhookDeliveries from '@codraoss/db/webhook-deliveries';

import { GitHubClient, normalizeGitHubWebhook } from '@codra/provider-github';
import { GitHubClient, normalizeGitHubWebhook } from '@codraoss/provider-github';
import { GitHubIdentityProvider } from '@codraoss/provider-github/oauth';
import { getGlobalConfig, updateGlobalConfig, loadRepoConfig, invalidateRepoConfigCache } from '../../../src/server/core/config';

import { getUpdatesEmailPreference, syncUpdatesEmail } from '../../../src/server/core/updates-email';
Expand All @@ -23,11 +24,12 @@ import { createOAuthState, consumeOAuthState } from '../../../src/server/core/oa
import { verifyGitHubWebhookSignature } from '../../../src/server/core/verify';

import { CloudflareSessionStore } from './sessions';
import { makeKvStore } from '../../../src/server/adapters/platform';
import { logger } from '../../../src/server/core/logger';

// model sync dependencies
import { listLlmProviderSecrets, upsertDiscoveredModelConfigs, createLlmProvider, updateLlmProvider, getResolvedModelConfig, getLlmProvider } from '@codra/db/model-configs';
import { encryptLlmApiKey, decryptLlmApiKey, listProviderModels, reviewWithCloudflare, reviewWithGoogle, reviewWithVertex, reviewWithOpenAI, reviewWithAnthropic, ProviderRequestError } from '@codra/models';
import { listLlmProviderSecrets, upsertDiscoveredModelConfigs, createLlmProvider, updateLlmProvider, getResolvedModelConfig, getLlmProvider } from '@codraoss/db/model-configs';
import { encryptLlmApiKey, decryptLlmApiKey, listProviderModels, reviewWithCloudflare, reviewWithGoogle, reviewWithVertex, reviewWithOpenAI, reviewWithAnthropic, ProviderRequestError } from '@codraoss/models';
import { buildReviewResponseSchema } from '../../../src/server/prompts/file-review';

function getSecretStore(env: AppBindings) {
Expand All @@ -43,6 +45,12 @@ function optionalEnv(value: () => string) {
}
}

// `IDENTITY_PROVIDER` is a test-only seam; production has no such binding.
const githubIdentity = new GitHubIdentityProvider();
function identityProvider(env: AppBindings) {
return ((env as any).IDENTITY_PROVIDER as any) ?? githubIdentity;
}

export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): ApiRouterDeps {
return {
repositories: {
Expand Down Expand Up @@ -232,9 +240,7 @@ export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): A
);
} catch (e) { /* ignore */ }
},
createReviewRuntime: () => {
throw new Error('Not implemented for this context');
},
createReviewRuntime: () => ({ kv: makeKvStore(env) } as any),
getUpdatesEmailPreference: async (githubUserId: number) => await getUpdatesEmailPreference(env as any, githubUserId),
syncUpdatesEmail: async (githubUserId: number, email: string | null | undefined) => await syncUpdatesEmail(env as any, githubUserId, email),
terminateJobWorkflow: async (job: { id: string; workflowInstanceId?: string | null }) => {
Expand All @@ -256,8 +262,10 @@ export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): A
authProvider: {
createOAuthState: async () => await createOAuthState(env as any),
consumeOAuthState: async (state: string) => await consumeOAuthState(env as any, state),
beginAuthorization: async (callbackUrl: string, state: string) => await ((env as any).IDENTITY_PROVIDER as any).beginAuthorization(callbackUrl, state, env),
completeAuthorization: async (code: string, state: string, expectedState: string) => await ((env as any).IDENTITY_PROVIDER as any).completeAuthorization(code, state, expectedState, env),
beginAuthorization: async (callbackUrl: string, state: string) =>
await identityProvider(env).beginAuthorization(callbackUrl, state, env),
completeAuthorization: async (code: string, state: string, expectedState: string) =>
await identityProvider(env).completeAuthorization(code, state, expectedState, env),
},
webhook: {
verifySignature: async (signature: string | null, body: string) => await verifyGitHubWebhookSignature(env.GITHUB_APP_WEBHOOK_SECRET, signature, body),
Expand Down
4 changes: 2 additions & 2 deletions apps/worker/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReviewJobMessage } from '@codra/schema';
import type { DashboardSessionUser, SessionStore } from '@codra/core';
import type { ReviewJobMessage } from '@codraoss/schema';
import type { DashboardSessionUser, SessionStore } from '@codraoss/core';

export interface WorkersAiBinding {
run(model: string, input: Record<string, unknown>, options?: { signal?: AbortSignal }): Promise<any>;
Expand Down
29 changes: 9 additions & 20 deletions apps/worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { createApiRouter } from '@codra/api';
import { createApiRouter } from '@codraoss/api';
import { createApiRouterDeps } from './api-deps';
import { ReviewWorkflow } from './workflows/review';
import type { AppBindings } from './env';
import { reviewJobMessageSchema } from '@codra/schema';
import { reviewJobMessageSchema } from '@codraoss/schema';
import { logger } from '@server/core/logger';
import { disposeRpc } from '@server/core/rpc';
import { runWithDb } from '@codra/db/client';
import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@codra/db/jobs';
import { runWithDb } from '@codraoss/db/client';
import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@codraoss/db/jobs';
import { runBestEffortJobMaintenance } from '@server/core/job-recovery';

const app = createApiRouter();
Expand All @@ -23,10 +23,7 @@ export default {
},

async scheduled(_controller: ScheduledController, env: AppBindings, _ctx: ExecutionContext) {
// The cron fires every 2 minutes but only does maintenance (recovering stuck jobs, finishing
// check runs). Touching Postgres every tick would keep the serverless DB awake 24/7, so gate on
// a KV flag set whenever a job is created/claimed and cleared once nothing is left to maintain
// -- when it's absent we return without ever opening a DB connection.
// Gate on KV flag: avoids waking the serverless DB every 2min tick when nothing's pending.
try {
const active = await env.APP_KV.get('system:active_jobs');
if (!active) {
Expand All @@ -38,8 +35,7 @@ export default {

return runWithDb(env, async () => {
await runBestEffortJobMaintenance(env);
// Drop the flag as soon as nothing is left to maintain, so the next tick skips Postgres
// instead of waiting out the 20-minute TTL; a new job re-sets it on insert/claim.
// Clear flag early so next tick skips DB instead of waiting for TTL.
try {
if (!(await hasPendingMaintenanceWork(env))) {
await clearSystemActive(env);
Expand All @@ -58,9 +54,7 @@ export default {
logger.error('Pre-batch maintenance task failed', error instanceof Error ? error : new Error(String(error)));
}

// Sequential by design: each iteration creates a Workflow instance (a subrequest), and a
// batch can carry enough messages that fanning out would breach the Workers simultaneous-
// subrequest cap on the Free plan.
// Sequential: parallel fan-out could breach the Free plan subrequest cap.
for (const message of batch.messages) {
const parseResult = reviewJobMessageSchema.safeParse(message.body);

Expand All @@ -69,9 +63,7 @@ export default {
body: message.body,
error: parseResult.error.flatten(),
});
// A malformed message can't be processed and retrying won't help, so ack it -- but if it
// still carries a recognizable jobId, fail that job so it doesn't sit 'queued' forever
// (lease recovery only revives 'running' rows).
// Ack (retry won't help); fail the job too, since lease recovery only revives 'running' rows.
const strandedId = (message.body as { jobId?: unknown })?.jobId;
if (typeof strandedId === 'string' && /^[0-9a-f-]{36}$/i.test(strandedId)) {
try {
Expand All @@ -87,10 +79,7 @@ export default {
const { jobId, deliveryId, forceFreshInstance } = parseResult.data;

try {
// Recovery re-enqueues a stuck job under its original jobId; keying the instance on jobId
// would collide with the dead instance (instance.already_exists), so recovery sets
// forceFreshInstance to key the new instance on the (fresh) deliveryId -- a UUID,
// matching workflow_instance_id's column type.
// forceFreshInstance keys on deliveryId (UUID) to avoid instance.already_exists on the dead jobId-keyed instance.
const id = forceFreshInstance ? deliveryId : (jobId ?? deliveryId);
if (!id) {
logger.error('Message missing identifiers; dropping', { body: message.body });
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/ports/cloudflare-kv.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { KeyValueStore } from '@codra/core';
import type { KeyValueStore } from '@codraoss/core';

export class CloudflareKV implements KeyValueStore {
constructor(private readonly kv: KVNamespace) {}
Expand Down
10 changes: 5 additions & 5 deletions apps/worker/src/ports/cloudflare-orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { JobOrchestrator } from '@codra/core';
import type { ReviewJobMessage } from '@codra/schema';
import { FRESH_INVOCATION_YIELD_SECONDS } from '@codra/core';
import type { JobOrchestrator } from '@codraoss/core';
import type { ReviewJobMessage } from '@codraoss/schema';
import { FRESH_INVOCATION_YIELD_SECONDS } from '@codraoss/core';
import { runReviewJob } from '@server/core/review';
import { setJobWorkflowInstance } from '@codra/db/jobs';
import { logger } from '@codra/core/logger';
import { setJobWorkflowInstance } from '@codraoss/db/jobs';
import { logger } from '@codraoss/core/logger';
import { runBestEffortJobMaintenance } from '@server/core/job-recovery';
import type { AppBindings } from '../env';
import type { WorkflowStep } from 'cloudflare:workers';
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DashboardSessionUser, SessionStore } from '@codra/core';
import type { DashboardSessionUser, SessionStore } from '@codraoss/core';

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