Add agent workflows & stuff
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 21:15:15 -05:00
parent cf7ff2ee4e
commit 2dfa97ee4f
102 changed files with 8488 additions and 161 deletions
+479
View File
@@ -0,0 +1,479 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
const jobStatus = v.union(
v.literal('queued'),
v.literal('claimed'),
v.literal('preparing'),
v.literal('running'),
v.literal('checks_running'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('failed'),
v.literal('cancelled'),
v.literal('timed_out'),
);
const eventLevel = v.union(
v.literal('debug'),
v.literal('info'),
v.literal('warn'),
v.literal('error'),
);
const eventPhase = v.union(
v.literal('queued'),
v.literal('clone'),
v.literal('plan'),
v.literal('edit'),
v.literal('install'),
v.literal('check'),
v.literal('test'),
v.literal('commit'),
v.literal('push'),
v.literal('pr'),
v.literal('cleanup'),
);
const artifactKind = v.union(
v.literal('plan'),
v.literal('diff'),
v.literal('test_output'),
v.literal('summary'),
v.literal('error'),
v.literal('pr_body'),
);
const artifactContentType = v.union(
v.literal('text/markdown'),
v.literal('text/plain'),
v.literal('application/json'),
v.literal('text/x-diff'),
);
const defaultAgentSettings = {
enabled: true,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high' as const,
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
};
const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();
const requireWorkerToken = (workerToken: string) => {
const expected = getWorkerToken();
if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
};
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 36) || 'task';
const shortId = (id: string) => id.replace(/[^a-zA-Z0-9]/g, '').slice(-8);
const buildBranch = (
requestId: Id<'agentRequests'>,
prompt: string,
prefix: string,
requestedBranchName?: string,
) => {
const requested = optionalText(requestedBranchName);
if (requested) return requested.replace(/^\/+|\/+$/g, '');
return `${prefix.replace(/\/+$/g, '')}/${shortId(requestId)}/${slugify(
prompt,
)}`;
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
.first();
return {
...defaultAgentSettings,
defaultBaseBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
...settings,
};
};
const assertSecretOwnership = async (
ctx: MutationCtx,
spoonId: Id<'spoons'>,
ownerId: Id<'users'>,
secretIds: Id<'spoonSecrets'>[],
) => {
for (const secretId of secretIds) {
const secret = await ctx.db.get(secretId);
if (secret?.ownerId !== ownerId || secret.spoonId !== spoonId) {
throw new ConvexError('Selected secrets must belong to this Spoon.');
}
}
};
export const createFromRequest = mutation({
args: {
requestId: v.id('agentRequests'),
selectedSecretIds: v.array(v.id('spoonSecrets')),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const request = await ctx.db.get(args.requestId);
if (request?.ownerId !== ownerId) {
throw new ConvexError('Agent request not found.');
}
if (request.agentJobId) {
throw new ConvexError('This request already has an agent job.');
}
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
if (spoon.provider !== 'github') {
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
}
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
throw new ConvexError(
'Add fork repository metadata before queueing a job.',
);
}
const settings = await getAgentSettings(ctx, spoon);
if (!settings.enabled) {
throw new ConvexError('Agent jobs are disabled for this Spoon.');
}
await assertSecretOwnership(
ctx,
spoon._id,
ownerId,
args.selectedSecretIds,
);
const now = Date.now();
const baseBranch =
optionalText(args.baseBranch) ?? settings.defaultBaseBranch;
const workBranch = buildBranch(
request._id,
request.prompt,
settings.branchPrefix,
args.requestedBranchName,
);
const jobId = await ctx.db.insert('agentJobs', {
spoonId: spoon._id,
ownerId,
agentRequestId: request._id,
status: 'queued',
prompt: request.prompt,
baseBranch,
workBranch,
githubInstallationId: spoon.githubInstallationId,
forkOwner: spoon.forkOwner,
forkRepo: spoon.forkRepo,
forkUrl: spoon.forkUrl,
upstreamOwner: spoon.upstreamOwner,
upstreamRepo: spoon.upstreamRepo,
selectedSecretIds: args.selectedSecretIds,
model: settings.agentModel,
reasoningEffort: settings.reasoningEffort,
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(request._id, {
agentJobId: jobId,
selectedSecretIds: args.selectedSecretIds,
baseBranch,
requestedBranchName: optionalText(args.requestedBranchName),
status: 'queued',
updatedAt: now,
});
await ctx.db.insert('agentJobEvents', {
jobId,
spoonId: spoon._id,
ownerId,
level: 'info',
phase: 'queued',
message: 'Agent job queued.',
createdAt: now,
});
return jobId;
},
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('agentJobs')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const get = query({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return job;
},
});
export const listEvents = query({
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
handler: async (ctx, { jobId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return await ctx.db
.query('agentJobEvents')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('asc')
.take(limit ?? 200);
},
});
export const listArtifacts = query({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return await ctx.db
.query('agentJobArtifacts')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('asc')
.collect();
},
});
export const cancel = mutation({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
if (
!['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
job.status,
)
) {
throw new ConvexError('This job cannot be cancelled.');
}
const now = Date.now();
await ctx.db.patch(jobId, {
status: 'cancelled',
completedAt: now,
updatedAt: now,
});
await ctx.db.patch(job.agentRequestId, {
status: 'cancelled',
updatedAt: now,
});
await ctx.db.insert('agentJobEvents', {
jobId,
spoonId: job.spoonId,
ownerId,
level: 'warn',
phase: 'cleanup',
message: 'Agent job cancelled by user.',
createdAt: now,
});
return { success: true };
},
});
export const claimNextInternal = internalMutation({
args: { workerId: v.string() },
handler: async (ctx, { workerId }) => {
const job = await ctx.db
.query('agentJobs')
.withIndex('by_claim', (q) => q.eq('status', 'queued'))
.order('asc')
.first();
if (!job) return null;
const spoon = await ctx.db.get(job.spoonId);
const aiSettings = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', job.ownerId).eq('provider', 'openai'),
)
.first();
const agentSettings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
.first();
const secrets = [];
for (const secretId of job.selectedSecretIds) {
const secret = await ctx.db.get(secretId);
if (secret?.ownerId === job.ownerId && secret.spoonId === job.spoonId) {
secrets.push(secret);
}
}
const now = Date.now();
await ctx.db.patch(job._id, {
status: 'claimed',
claimedBy: workerId,
claimedAt: now,
updatedAt: now,
});
await ctx.db.patch(job.agentRequestId, {
status: 'running',
updatedAt: now,
});
await ctx.db.insert('agentJobEvents', {
jobId: job._id,
spoonId: job.spoonId,
ownerId: job.ownerId,
level: 'info',
phase: 'queued',
message: `Claimed by ${workerId}.`,
createdAt: now,
});
return {
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
spoon,
aiSettings,
agentSettings,
secrets,
};
},
});
export const updateStatus = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
status: jobStatus,
error: v.optional(v.string()),
summary: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
const patch: Partial<Doc<'agentJobs'>> = {
status: args.status,
updatedAt: now,
};
if (args.status === 'running' && !job.startedAt) patch.startedAt = now;
if (
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
args.status,
)
) {
patch.completedAt = now;
}
if (args.error !== undefined) patch.error = args.error;
if (args.summary !== undefined) patch.summary = args.summary;
await ctx.db.patch(args.jobId, patch);
return { success: true };
},
});
export const completeWithDraftPr = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
commitSha: v.string(),
pullRequestUrl: v.string(),
pullRequestNumber: v.number(),
summary: v.string(),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
await ctx.db.patch(args.jobId, {
status: 'draft_pr_opened',
commitSha: args.commitSha,
pullRequestUrl: args.pullRequestUrl,
pullRequestNumber: args.pullRequestNumber,
summary: args.summary,
completedAt: now,
updatedAt: now,
});
await ctx.db.patch(job.agentRequestId, {
status: 'merge_request_opened',
mergeRequestUrl: args.pullRequestUrl,
summary: args.summary,
updatedAt: now,
});
return { success: true };
},
});
export const appendEvent = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
level: eventLevel,
phase: eventPhase,
message: v.string(),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
return await ctx.db.insert('agentJobEvents', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
level: args.level,
phase: args.phase,
message: args.message,
metadata: args.metadata,
createdAt: Date.now(),
});
},
});
export const addArtifact = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
kind: artifactKind,
title: v.string(),
content: v.string(),
contentType: artifactContentType,
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
return await ctx.db.insert('agentJobArtifacts', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
kind: args.kind,
title: args.title,
content: args.content,
contentType: args.contentType,
createdAt: Date.now(),
});
},
});
+82
View File
@@ -0,0 +1,82 @@
'use node';
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { decryptSecret } from './secretCrypto';
type ClaimedJob = {
job: Doc<'agentJobs'>;
spoon: Doc<'spoons'> | null;
aiSettings: Doc<'userAiSettings'> | null;
agentSettings: Doc<'spoonAgentSettings'> | null;
secrets: Doc<'spoonSecrets'>[];
};
type WorkerClaim = {
job: Doc<'agentJobs'>;
spoon: Doc<'spoons'>;
openai: {
apiKey: string;
model: string;
reasoningEffort: Doc<'agentJobs'>['reasoningEffort'];
};
agentSettings: Doc<'spoonAgentSettings'> | null;
github: {
installationId?: string;
};
secrets: { name: string; value: string }[];
};
const requireWorkerToken = (workerToken: string) => {
const expected = process.env.SPOON_WORKER_TOKEN?.trim();
if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
};
export const claimNextForWorker = action({
args: {
workerId: v.string(),
workerToken: v.string(),
},
handler: async (ctx, args): Promise<WorkerClaim | null> => {
requireWorkerToken(args.workerToken);
const claimed = (await ctx.runMutation(
internal.agentJobs.claimNextInternal,
{
workerId: args.workerId,
},
)) as ClaimedJob | null;
if (!claimed) return null;
if (!claimed.spoon) {
throw new ConvexError('Claimed job points at a missing Spoon.');
}
if (!claimed.aiSettings?.encryptedApiKey) {
throw new ConvexError(
'OpenAI is not configured for this user. Add an OpenAI API key in settings.',
);
}
return {
job: claimed.job,
spoon: claimed.spoon,
openai: {
apiKey: decryptSecret(claimed.aiSettings.encryptedApiKey),
model: claimed.job.model,
reasoningEffort: claimed.job.reasoningEffort,
},
agentSettings: claimed.agentSettings,
github: {
installationId:
claimed.job.githubInstallationId ??
claimed.spoon.githubInstallationId ??
process.env.GITHUB_APP_INSTALLATION_ID,
},
secrets: claimed.secrets.map((secret: Doc<'spoonSecrets'>) => ({
name: secret.name,
value: decryptSecret(secret.encryptedValue),
})),
};
},
});
+40
View File
@@ -20,6 +20,19 @@ export const listRecent = query({
},
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('agentRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const create = mutation({
args: {
spoonId: v.id('spoons'),
@@ -35,6 +48,9 @@ export const create = mutation({
ownerId,
prompt: requireText(args.prompt, 'Prompt'),
status: 'queued',
requestType: 'future_code_change',
priority: 'normal',
source: 'user',
targetBranch: optionalText(args.targetBranch),
createdAt: now,
updatedAt: now,
@@ -42,6 +58,30 @@ export const create = mutation({
},
});
export const updatePrompt = mutation({
args: {
requestId: v.id('agentRequests'),
prompt: v.string(),
targetBranch: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const request = await ctx.db.get(args.requestId);
if (request?.ownerId !== ownerId) {
throw new ConvexError('Agent request not found.');
}
if (request.status !== 'draft' && request.status !== 'queued') {
throw new ConvexError('Only draft or queued requests can be edited.');
}
await ctx.db.patch(args.requestId, {
prompt: requireText(args.prompt, 'Prompt'),
targetBranch: optionalText(args.targetBranch),
updatedAt: Date.now(),
});
return { success: true };
},
});
export const cancel = mutation({
args: { requestId: v.id('agentRequests') },
handler: async (ctx, { requestId }) => {
+201
View File
@@ -0,0 +1,201 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { reviewUpstreamCompatibility } from './openaiClient';
import { decryptSecret } from './secretCrypto';
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
export const reviewLatestUpstreamChanges = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
reviewId: Id<'aiReviews'>;
risk: 'low' | 'medium' | 'high';
recommendedAction:
| 'sync'
| 'open_review_pr'
| 'manual_review'
| 'do_not_sync';
}> => {
const ownerId = await getRequiredUserId(ctx);
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
const [state, settings, upstreamCommits, forkCommits]: [
Doc<'spoonRepositoryStates'> | null,
Doc<'spoonSettings'> | null,
Doc<'spoonCommits'>[],
Doc<'spoonCommits'>[],
] = await Promise.all([
ctx.runQuery(internal.spoonState.getInternal, { spoonId, ownerId }),
ctx.runQuery(internal.spoonSettings.getInternal, { spoonId, ownerId }),
ctx.runQuery(internal.spoonCommits.listInternal, {
spoonId,
ownerId,
side: 'upstream',
limit: 80,
}),
ctx.runQuery(internal.spoonCommits.listInternal, {
spoonId,
ownerId,
side: 'fork',
limit: 80,
}),
]);
const aiSettings: Doc<'userAiSettings'> | null = await ctx.runQuery(
internal.aiSettings.getForUserInternal,
{ userId: ownerId },
);
if (!aiSettings?.encryptedApiKey) {
throw new ConvexError(
'Add your OpenAI API key in Settings before running AI review.',
);
}
const model = aiSettings.model;
const syncRunId: Id<'syncRuns'> = await ctx.runMutation(
internal.syncRuns.createInternal,
{
spoonId,
ownerId,
kind: 'ai_review',
status: 'running',
summary: 'Reviewing upstream changes with OpenAI.',
},
);
const reviewId: Id<'aiReviews'> = await ctx.runMutation(
internal.aiReviews.createInternal,
{
spoonId,
ownerId,
syncRunId,
model,
status: 'running',
reviewType: 'upstream_update',
inputSummary: `${upstreamCommits.length} upstream commit(s), ${forkCommits.length} fork-only commit(s).`,
},
);
try {
if (upstreamCommits.length === 0) {
await ctx.runMutation(internal.aiReviews.completeInternal, {
reviewId,
outputSummary: 'The fork is already up to date with upstream.',
risk: 'low',
compatible: true,
requiresHumanReview: false,
recommendedAction: 'sync',
potentialConflicts: [],
importantFiles: [],
reasoningSummary:
'No upstream-only commits are cached for this Spoon, so there is nothing to review.',
});
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
lastAiReviewId: reviewId,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'clean',
aiAssessment: 'No upstream-only commits are waiting.',
}),
]);
return { reviewId, risk: 'low', recommendedAction: 'sync' };
}
const review = await reviewUpstreamCompatibility(
{
spoonName: spoon.name,
upstreamFullName:
state?.upstreamFullName ??
`${spoon.upstreamOwner}/${spoon.upstreamRepo}`,
forkFullName:
state?.forkFullName ??
`${spoon.forkOwner ?? 'unknown'}/${spoon.forkRepo ?? 'unknown'}`,
status: state?.status ?? spoon.syncStatus ?? 'unknown',
upstreamAheadBy: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
upstreamCommits: upstreamCommits.map((commit) => ({
sha: commit.sha,
message: commit.message,
authorName: commit.authorName,
committedAt: commit.committedAt,
})),
forkCommits: forkCommits.map((commit) => ({
sha: commit.sha,
message: commit.message,
authorName: commit.authorName,
committedAt: commit.committedAt,
})),
importantFilePatterns: settings?.importantFilePatterns,
ignoredFilePatterns: settings?.ignoredFilePatterns,
},
{
apiKey: decryptSecret(aiSettings.encryptedApiKey),
model,
reasoningEffort: aiSettings.reasoningEffort,
},
);
await ctx.runMutation(internal.aiReviews.completeInternal, {
reviewId,
outputSummary: review.summary,
risk: review.risk,
compatible: review.compatible,
requiresHumanReview: review.requiresHumanReview,
recommendedAction: review.recommendedAction,
potentialConflicts: review.potentialConflicts,
importantFiles: review.importantFiles,
reasoningSummary: review.reasoningSummary,
});
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
lastAiReviewId: reviewId,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status:
review.compatible && review.risk === 'low'
? 'clean'
: 'needs_review',
aiAssessment: review.summary,
}),
]);
return {
reviewId,
risk: review.risk,
recommendedAction: review.recommendedAction,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await Promise.all([
ctx.runMutation(internal.aiReviews.failInternal, {
reviewId,
error: message,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error: message,
}),
]);
throw new ConvexError(message);
}
},
});
+140
View File
@@ -0,0 +1,140 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const reviewStatus = v.union(
v.literal('queued'),
v.literal('running'),
v.literal('completed'),
v.literal('failed'),
);
const reviewType = v.union(
v.literal('upstream_update'),
v.literal('manual_prompt'),
v.literal('merge_safety'),
);
const risk = v.union(
v.literal('unknown'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
);
const recommendedAction = v.union(
v.literal('sync'),
v.literal('open_review_pr'),
v.literal('manual_review'),
v.literal('do_not_sync'),
v.literal('unknown'),
);
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const getLatestForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.first();
},
});
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const ownerId = await getRequiredUserId(ctx);
return await ctx.db
.query('aiReviews')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.take(limit ?? 25);
},
});
export const createInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
syncRunId: v.optional(v.id('syncRuns')),
model: v.string(),
status: reviewStatus,
reviewType,
inputSummary: v.string(),
},
handler: async (ctx, args): Promise<Id<'aiReviews'>> => {
const now = Date.now();
return await ctx.db.insert('aiReviews', {
...args,
risk: 'unknown',
compatible: false,
requiresHumanReview: true,
recommendedAction: 'unknown',
createdAt: now,
updatedAt: now,
});
},
});
export const completeInternal = internalMutation({
args: {
reviewId: v.id('aiReviews'),
outputSummary: v.string(),
risk,
compatible: v.boolean(),
requiresHumanReview: v.boolean(),
recommendedAction,
potentialConflicts: v.array(v.string()),
importantFiles: v.array(v.string()),
reasoningSummary: v.string(),
},
handler: async (ctx, args) => {
const review = await ctx.db.get(args.reviewId);
if (!review) throw new ConvexError('AI review not found.');
const patch: Partial<Doc<'aiReviews'>> = {
status: 'completed',
outputSummary: args.outputSummary,
risk: args.risk,
compatible: args.compatible,
requiresHumanReview: args.requiresHumanReview,
recommendedAction: args.recommendedAction,
potentialConflicts: args.potentialConflicts,
importantFiles: args.importantFiles,
reasoningSummary: args.reasoningSummary,
updatedAt: Date.now(),
completedAt: Date.now(),
};
await ctx.db.patch(args.reviewId, patch);
return review;
},
});
export const failInternal = internalMutation({
args: { reviewId: v.id('aiReviews'), error: v.string() },
handler: async (ctx, { reviewId, error }) => {
await ctx.db.patch(reviewId, {
status: 'failed',
error,
updatedAt: Date.now(),
});
return { success: true };
},
});
+141
View File
@@ -0,0 +1,141 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getRequiredUserId } from './model';
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
export const getMine = query({
args: {},
handler: async (ctx) => {
const userId = await getRequiredUserId(ctx);
const settings = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
if (!settings) {
return {
configured: false,
apiKeyPreview: undefined,
model: 'gpt-5.5',
reasoningEffort: 'medium' as const,
updatedAt: undefined,
};
}
return {
configured: Boolean(settings.encryptedApiKey),
apiKeyPreview: settings.apiKeyPreview,
model: settings.model,
reasoningEffort: settings.reasoningEffort,
updatedAt: settings.updatedAt,
};
},
});
export const getForUserInternal = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
return await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
},
});
const upsert = async (
ctx: MutationCtx,
userId: Id<'users'>,
patch: Partial<Doc<'userAiSettings'>>,
) => {
const now = Date.now();
const existing = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
if (existing) {
await ctx.db.patch(existing._id, { ...patch, updatedAt: now });
return existing._id;
}
return await ctx.db.insert('userAiSettings', {
userId,
provider: 'openai',
model: patch.model ?? 'gpt-5.5',
reasoningEffort: patch.reasoningEffort ?? 'medium',
encryptedApiKey: patch.encryptedApiKey,
apiKeyPreview: patch.apiKeyPreview,
createdAt: now,
updatedAt: now,
});
};
export const upsertEncryptedInternal = internalMutation({
args: {
userId: v.id('users'),
encryptedApiKey: v.string(),
apiKeyPreview: v.string(),
model: v.string(),
reasoningEffort,
},
handler: async (ctx, args) => {
return await upsert(ctx, args.userId, {
encryptedApiKey: args.encryptedApiKey,
apiKeyPreview: args.apiKeyPreview,
model: args.model,
reasoningEffort: args.reasoningEffort,
});
},
});
export const updatePreferences = mutation({
args: {
model: v.string(),
reasoningEffort,
},
handler: async (ctx, args) => {
const userId = await getRequiredUserId(ctx);
return await upsert(ctx, userId, {
model: args.model.trim() || 'gpt-5.5',
reasoningEffort: args.reasoningEffort,
});
},
});
export const removeOpenAiKey = mutation({
args: {},
handler: async (ctx) => {
const userId = await getRequiredUserId(ctx);
const settings = await ctx.db
.query('userAiSettings')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'openai'),
)
.first();
if (!settings) throw new ConvexError('OpenAI settings not found.');
await ctx.db.patch(settings._id, {
encryptedApiKey: undefined,
apiKeyPreview: undefined,
updatedAt: Date.now(),
});
return { success: true };
},
});
+52
View File
@@ -0,0 +1,52 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { encryptSecret } from './secretCrypto';
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const previewKey = (apiKey: string) => {
const trimmed = apiKey.trim();
if (trimmed.length <= 10) return 'configured';
return `${trimmed.slice(0, 7)}...${trimmed.slice(-4)}`;
};
export const saveOpenAiSettings = action({
args: {
apiKey: v.string(),
model: v.string(),
reasoningEffort,
},
handler: async (ctx, args): Promise<{ success: true }> => {
const userId = await getRequiredUserId(ctx);
const apiKey = args.apiKey.trim();
if (!apiKey) throw new ConvexError('OpenAI API key is required.');
await ctx.runMutation(internal.aiSettings.upsertEncryptedInternal, {
userId,
encryptedApiKey: encryptSecret(apiKey),
apiKeyPreview: previewKey(apiKey),
model: args.model.trim() || 'gpt-5.5',
reasoningEffort: args.reasoningEffort,
});
return { success: true };
},
});
+14 -1
View File
@@ -1,4 +1,5 @@
import Authentik from '@auth/core/providers/authentik';
import GitHub from '@auth/core/providers/github';
import {
convexAuth,
getAuthUserId,
@@ -21,6 +22,12 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
GitHub({
allowDangerousEmailAccountLinking: true,
clientId: process.env.AUTH_GITHUB_ID ?? process.env.GITHUB_APP_CLIENT_ID,
clientSecret:
process.env.AUTH_GITHUB_SECRET ?? process.env.GITHUB_APP_CLIENT_SECRET,
}),
Password,
],
});
@@ -39,6 +46,7 @@ const getAuthAccountById = async (ctx: QueryCtx, userId: Id<'users'>) => {
const authAccount = await ctx.db
.query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
.order('desc')
.first();
if (!authAccount) throw new ConvexError('Auth account not found');
return authAccount;
@@ -83,7 +91,12 @@ export const updateUser = mutation({
if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined;
patch.image = args.image;
if (oldImage && oldImage !== args.image) {
if (
oldImage &&
oldImage !== args.image &&
!oldImage.startsWith('http://') &&
!oldImage.startsWith('https://')
) {
await ctx.storage.delete(oldImage);
}
}
+10
View File
@@ -1,7 +1,17 @@
import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
const crons = cronJobs();
crons.interval(
'Refresh due GitHub Spoons',
{ hours: 1 },
internal.githubSync.refreshDueSpoons,
{
limit: 10,
},
);
/* Example cron jobs
crons.cron(
// Run at 7:00 AM CST / 8:00 AM CDT
+12 -3
View File
@@ -1,6 +1,7 @@
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
export const generateUploadUrl = mutation(async (ctx) => {
@@ -9,10 +10,18 @@ export const generateUploadUrl = mutation(async (ctx) => {
return await ctx.storage.generateUploadUrl();
});
const isRemoteImageUrl = (value: string) =>
value.startsWith('http://') || value.startsWith('https://');
export const getImageUrl = query({
args: { storageId: v.id('_storage') },
args: { storageId: v.string() },
handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId);
return url ?? null;
if (isRemoteImageUrl(storageId)) return storageId;
try {
const url = await ctx.storage.getUrl(storageId as Id<'_storage'>);
return url ?? null;
} catch {
return null;
}
},
});
+214
View File
@@ -0,0 +1,214 @@
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getRequiredUserId } from './model';
export const getInstallUrl = query({
args: {},
handler: () => {
const slug = process.env.GITHUB_APP_SLUG;
if (!slug) return null;
return `https://github.com/apps/${slug}/installations/new`;
},
});
export const getConnection = query({
args: {},
handler: async (ctx) => {
const userId = await getRequiredUserId(ctx);
return await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'github'),
)
.first();
},
});
export const connectInstallation = mutation({
args: { installationId: v.string() },
handler: async (ctx, { installationId }) => {
const userId = await getRequiredUserId(ctx);
const trimmedInstallationId = installationId.trim();
if (!trimmedInstallationId) {
throw new ConvexError('GitHub installation ID is required.');
}
const now = Date.now();
const existing = await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'github'),
)
.first();
const patch = {
provider: 'github' as const,
displayName: `GitHub installation ${trimmedInstallationId}`,
installationId: trimmedInstallationId,
scopes: [
'metadata:read',
'administration:write',
'contents:write',
'pull_requests:write',
],
status: 'active' as const,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('gitConnections', {
userId,
...patch,
connectedAt: now,
});
},
});
export const getConnectionForUser = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
return await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', userId).eq('provider', 'github'),
)
.first();
},
});
export const upsertConnectionForUser = internalMutation({
args: {
userId: v.id('users'),
providerAccountId: v.optional(v.string()),
displayName: v.string(),
username: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
installationId: v.string(),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query('gitConnections')
.withIndex('by_user_provider', (q) =>
q.eq('userId', args.userId).eq('provider', 'github'),
)
.first();
const patch = {
providerAccountId: args.providerAccountId,
displayName: args.displayName,
username: args.username,
avatarUrl: args.avatarUrl,
installationId: args.installationId,
scopes: [
'metadata:read',
'administration:write',
'contents:write',
'pull_requests:write',
'checks:write',
'statuses:write',
'issues:write',
],
status: 'active' as const,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('gitConnections', {
userId: args.userId,
provider: 'github',
...patch,
connectedAt: now,
});
},
});
export const createForkSpoonRecord = internalMutation({
args: {
ownerId: v.id('users'),
name: v.string(),
description: v.optional(v.string()),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
upstreamDefaultBranch: v.string(),
upstreamUrl: v.string(),
forkOwner: v.string(),
forkRepo: v.string(),
forkDefaultBranch: v.string(),
forkUrl: v.string(),
visibility: v.union(
v.literal('public'),
v.literal('private'),
v.literal('internal'),
v.literal('unknown'),
),
connectionId: v.optional(v.id('gitConnections')),
},
handler: async (ctx, args): Promise<Id<'spoons'>> => {
const now = Date.now();
const spoonId = await ctx.db.insert('spoons', {
ownerId: args.ownerId,
name: args.name,
description: args.description,
provider: 'github',
upstreamOwner: args.upstreamOwner,
upstreamRepo: args.upstreamRepo,
upstreamDefaultBranch: args.upstreamDefaultBranch,
upstreamUrl: args.upstreamUrl,
forkOwner: args.forkOwner,
forkRepo: args.forkRepo,
forkDefaultBranch: args.forkDefaultBranch,
forkUrl: args.forkUrl,
visibility: args.visibility,
maintenanceMode: 'watch',
syncCadence: 'daily',
productionRefStrategy: 'default_branch',
status: 'active',
syncStatus: 'unknown',
connectionId: args.connectionId,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonSettings', {
spoonId,
ownerId: args.ownerId,
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [],
importantFilePatterns: [],
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('syncRuns', {
spoonId,
ownerId: args.ownerId,
kind: 'manual_check',
status: 'clean',
summary: `Created GitHub fork ${args.forkOwner}/${args.forkRepo} from ${args.upstreamOwner}/${args.upstreamRepo}.`,
createdAt: now,
updatedAt: now,
});
return spoonId;
},
});
+185
View File
@@ -0,0 +1,185 @@
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
import { ConvexError } from 'convex/values';
import type { Doc } from './_generated/dataModel';
export type GitHubCommitSummary = {
sha: string;
message: string;
authorName?: string;
authorEmail?: string;
authorLogin?: string;
committedAt?: number;
htmlUrl?: string;
filesChanged?: number;
additions?: number;
deletions?: number;
};
export type GitHubPullRequestSummary = {
githubId: number;
number: number;
repoFullName: string;
title: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
authorLogin?: string;
baseRef: string;
headRef: string;
headRepoFullName?: string;
htmlUrl: string;
createdAtGithub?: number;
updatedAtGithub?: number;
mergedAtGithub?: number;
};
export type GitHubCompareSummary = {
aheadBy: number;
mergeBaseSha?: string;
headSha?: string;
baseSha?: string;
htmlUrl?: string;
commits: GitHubCommitSummary[];
};
const getEnv = (name: string) => {
const value = process.env[name]?.trim();
if (!value) throw new ConvexError(`${name} is not configured.`);
return value;
};
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
export const getInstallationOctokit = (installationId: string) =>
new Octokit({
authStrategy: createAppAuth,
auth: {
appId: getEnv('GITHUB_APP_ID'),
privateKey: normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY')),
installationId,
},
userAgent: 'Spoon',
request: {
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
},
});
export const getSpoonInstallationId = (
spoon: Doc<'spoons'>,
connection?: Doc<'gitConnections'> | null,
) => {
const installationId =
spoon.githubInstallationId ?? connection?.installationId ?? undefined;
if (!installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
return installationId;
};
export const getRepository = async (
octokit: Octokit,
owner: string,
repo: string,
) => {
const result = await octokit.rest.repos.get({ owner, repo });
return result.data;
};
const toMillis = (value?: string | null) =>
value ? new Date(value).getTime() : undefined;
const normalizeCompareCommit = (
commit: Awaited<
ReturnType<Octokit['rest']['repos']['compareCommitsWithBasehead']>
>['data']['commits'][number],
): GitHubCommitSummary => ({
sha: commit.sha,
message: commit.commit.message,
authorName: commit.commit.author?.name ?? undefined,
authorEmail: commit.commit.author?.email ?? undefined,
authorLogin: commit.author?.login ?? undefined,
committedAt: toMillis(
commit.commit.author?.date ?? commit.commit.committer?.date,
),
htmlUrl: commit.html_url,
});
export const compareAcrossForkNetwork = async (
octokit: Octokit,
args: {
owner: string;
repo: string;
baseOwner: string;
baseBranch: string;
headOwner: string;
headBranch: string;
},
): Promise<GitHubCompareSummary> => {
const basehead = `${args.baseOwner}:${args.baseBranch}...${args.headOwner}:${args.headBranch}`;
const result = await octokit.rest.repos.compareCommitsWithBasehead({
owner: args.owner,
repo: args.repo,
basehead,
per_page: 100,
});
const commits = result.data.commits.map(normalizeCompareCommit);
return {
aheadBy: result.data.ahead_by,
mergeBaseSha: result.data.merge_base_commit.sha,
headSha: commits[commits.length - 1]?.sha,
baseSha: result.data.base_commit.sha,
htmlUrl: result.data.html_url,
commits,
};
};
const normalizePullRequest = (
repoFullName: string,
pull: Awaited<ReturnType<Octokit['rest']['pulls']['list']>>['data'][number],
): GitHubPullRequestSummary => ({
githubId: pull.id,
number: pull.number,
repoFullName,
title: pull.title,
state: pull.merged_at ? 'merged' : pull.state === 'open' ? 'open' : 'closed',
draft: pull.draft === true,
authorLogin: pull.user?.login ?? undefined,
baseRef: pull.base.ref,
headRef: pull.head.ref,
headRepoFullName: pull.head.repo.full_name,
htmlUrl: pull.html_url,
createdAtGithub: toMillis(pull.created_at),
updatedAtGithub: toMillis(pull.updated_at),
mergedAtGithub: toMillis(pull.merged_at),
});
export const listPullRequests = async (
octokit: Octokit,
args: { owner: string; repo: string; head?: string },
) => {
const result = await octokit.rest.pulls.list({
owner: args.owner,
repo: args.repo,
state: 'all',
per_page: 100,
head: args.head,
});
return result.data.map((pull) =>
normalizePullRequest(`${args.owner}/${args.repo}`, pull),
);
};
export const syncForkBranch = async (
octokit: Octokit,
args: { forkOwner: string; forkRepo: string; branch: string },
) => {
const result = await octokit.rest.repos.mergeUpstream({
owner: args.forkOwner,
repo: args.forkRepo,
branch: args.branch,
});
return result.data;
};
+261
View File
@@ -0,0 +1,261 @@
'use node';
import { createSign } from 'node:crypto';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
type GitHubInstallationAccount = {
id?: number;
login?: string;
avatar_url?: string;
};
type GitHubInstallation = {
id: number;
account?: GitHubInstallationAccount;
};
type GitHubRepository = {
id: number;
name: string;
full_name: string;
private: boolean;
fork: boolean;
html_url: string;
default_branch: string;
owner: {
login: string;
};
description?: string | null;
};
type GitHubListRepositoriesResponse = {
repositories: GitHubRepository[];
};
const base64Url = (value: string | Buffer) =>
Buffer.from(value)
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
const getEnv = (name: string) => {
const value = process.env[name]?.trim();
if (!value) throw new ConvexError(`${name} is not configured.`);
return value;
};
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
const firstText = (...values: (string | null | undefined)[]) =>
values.map((value) => value?.trim()).find((value) => value);
const createGitHubAppJwt = () => {
const appId = getEnv('GITHUB_APP_ID');
const privateKey = normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY'));
const now = Math.floor(Date.now() / 1000);
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64Url(
JSON.stringify({
iat: now - 60,
exp: now + 9 * 60,
iss: appId,
}),
);
const body = `${header}.${payload}`;
const signature = createSign('RSA-SHA256').update(body).sign(privateKey);
return `${body}.${base64Url(signature)}`;
};
const githubFetch = async <T>(
path: string,
token: string,
init: RequestInit = {},
): Promise<T> => {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'User-Agent': 'Spoon',
'X-GitHub-Api-Version': '2022-11-28',
...init.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new ConvexError(
`GitHub API request failed (${response.status}): ${body}`,
);
}
return (await response.json()) as T;
};
const createInstallationToken = async (installationId: string) => {
const jwt = createGitHubAppJwt();
const result = await githubFetch<{ token: string; expires_at: string }>(
`/app/installations/${installationId}/access_tokens`,
jwt,
{ method: 'POST' },
);
return result.token;
};
const getRequiredUserId = async (ctx: ActionCtx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
export const syncConfiguredInstallation = action({
args: {},
handler: async (ctx): Promise<Id<'gitConnections'>> => {
const userId = await getRequiredUserId(ctx);
const installationId = getEnv('GITHUB_APP_INSTALLATION_ID');
const jwt = createGitHubAppJwt();
const installation = await githubFetch<GitHubInstallation>(
`/app/installations/${installationId}`,
jwt,
);
const account = installation.account;
const displayName =
account?.login ?? `GitHub installation ${installationId}`;
return await ctx.runMutation(internal.github.upsertConnectionForUser, {
userId,
providerAccountId: account?.id?.toString(),
displayName,
username: account?.login,
avatarUrl: account?.avatar_url,
installationId: installation.id.toString(),
});
},
});
export const listInstallationRepositories = action({
args: {},
handler: async (
ctx,
): Promise<
{
id: number;
name: string;
fullName: string;
owner: string;
private: boolean;
fork: boolean;
url: string;
defaultBranch: string;
description?: string;
}[]
> => {
const userId = await getRequiredUserId(ctx);
const connection = await ctx.runQuery(
internal.github.getConnectionForUser,
{
userId,
},
);
if (!connection?.installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
const token = await createInstallationToken(connection.installationId);
const result = await githubFetch<GitHubListRepositoriesResponse>(
'/installation/repositories?per_page=100',
token,
);
return result.repositories.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
private: repo.private,
fork: repo.fork,
url: repo.html_url,
defaultBranch: repo.default_branch,
description: repo.description ?? undefined,
}));
},
});
export const createFork = action({
args: {
upstreamOwner: v.string(),
upstreamRepo: v.string(),
name: v.optional(v.string()),
description: v.optional(v.string()),
organization: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'spoons'>> => {
const userId = await getRequiredUserId(ctx);
const connection = await ctx.runQuery(
internal.github.getConnectionForUser,
{
userId,
},
);
if (!connection?.installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
const upstreamOwner = args.upstreamOwner.trim();
const upstreamRepo = args.upstreamRepo.trim();
if (!upstreamOwner || !upstreamRepo) {
throw new ConvexError('Upstream owner and repository are required.');
}
const token = await createInstallationToken(connection.installationId);
const upstream = await githubFetch<GitHubRepository>(
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
upstreamRepo,
)}`,
token,
);
const body: Record<string, string | boolean> = {
default_branch_only: false,
};
const forkName = args.name?.trim();
if (forkName) body.name = forkName;
const organization = args.organization?.trim();
if (organization) body.organization = organization;
const fork = await githubFetch<GitHubRepository>(
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
upstreamRepo,
)}/forks`,
token,
{
method: 'POST',
body: JSON.stringify(body),
},
);
const description = firstText(args.description, upstream.description);
return await ctx.runMutation(internal.github.createForkSpoonRecord, {
ownerId: userId,
name: fork.name,
description,
upstreamOwner,
upstreamRepo,
upstreamDefaultBranch: upstream.default_branch,
upstreamUrl: upstream.html_url,
forkOwner: fork.owner.login,
forkRepo: fork.name,
forkDefaultBranch: fork.default_branch,
forkUrl: fork.html_url,
visibility: fork.private ? 'private' : 'public',
connectionId: connection._id,
});
},
});
+368
View File
@@ -0,0 +1,368 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import type { GitHubCompareSummary } from './githubClient';
import { internal } from './_generated/api';
import { action, internalAction } from './_generated/server';
import {
compareAcrossForkNetwork,
getInstallationOctokit,
getRepository,
getSpoonInstallationId,
listPullRequests,
syncForkBranch,
} from './githubClient';
const getRequiredUserId = async (ctx: ActionCtx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const toStatus = (upstreamAheadBy: number, forkAheadBy: number) => {
if (upstreamAheadBy === 0 && forkAheadBy === 0) return 'up_to_date' as const;
if (upstreamAheadBy > 0 && forkAheadBy === 0) return 'behind' as const;
if (upstreamAheadBy === 0 && forkAheadBy > 0) return 'ahead' as const;
if (upstreamAheadBy > 0 && forkAheadBy > 0) return 'diverged' as const;
return 'unknown' as const;
};
const getLastCommitAt = (compare: GitHubCompareSummary) =>
compare.commits[compare.commits.length - 1]?.committedAt;
const ensureForkMetadata = (spoon: Doc<'spoons'>) => {
if (!spoon.forkOwner || !spoon.forkRepo) {
throw new ConvexError('Fork metadata is required before GitHub refresh.');
}
return {
forkOwner: spoon.forkOwner,
forkRepo: spoon.forkRepo,
forkBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
};
};
const refreshOwnedSpoon = async (
ctx: ActionCtx,
ownerId: Id<'users'>,
spoonId: Id<'spoons'>,
kind: 'manual_check' | 'scheduled_check' = 'manual_check',
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}> => {
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
if (spoon.provider !== 'github') {
throw new ConvexError(
'GitHub refresh is only available for GitHub Spoons.',
);
}
const connection = await ctx.runQuery(internal.github.getConnectionForUser, {
userId: ownerId,
});
const installationId = getSpoonInstallationId(spoon, connection);
const { forkOwner, forkRepo, forkBranch } = ensureForkMetadata(spoon);
const syncRunId = await ctx.runMutation(internal.syncRuns.createInternal, {
spoonId,
ownerId,
kind,
status: 'running',
summary: 'Refreshing GitHub repository state.',
});
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: 'checking',
lastSyncRunId: syncRunId,
lastGithubRefreshAt: Date.now(),
});
try {
const octokit = getInstallationOctokit(installationId);
const [upstreamRepo, forkRepoData] = await Promise.all([
getRepository(octokit, spoon.upstreamOwner, spoon.upstreamRepo),
getRepository(octokit, forkOwner, forkRepo),
]);
const upstreamBranch =
spoon.upstreamDefaultBranch || upstreamRepo.default_branch;
const resolvedForkBranch = forkBranch || forkRepoData.default_branch;
const [upstreamCompare, forkCompare, forkPulls, upstreamPulls]: [
GitHubCompareSummary,
GitHubCompareSummary,
Awaited<ReturnType<typeof listPullRequests>>,
Awaited<ReturnType<typeof listPullRequests>>,
] = await Promise.all([
compareAcrossForkNetwork(octokit, {
owner: spoon.upstreamOwner,
repo: spoon.upstreamRepo,
baseOwner: forkOwner,
baseBranch: resolvedForkBranch,
headOwner: spoon.upstreamOwner,
headBranch: upstreamBranch,
}),
compareAcrossForkNetwork(octokit, {
owner: spoon.upstreamOwner,
repo: spoon.upstreamRepo,
baseOwner: spoon.upstreamOwner,
baseBranch: upstreamBranch,
headOwner: forkOwner,
headBranch: resolvedForkBranch,
}),
listPullRequests(octokit, { owner: forkOwner, repo: forkRepo }),
listPullRequests(octokit, {
owner: spoon.upstreamOwner,
repo: spoon.upstreamRepo,
head: `${forkOwner}:${resolvedForkBranch}`,
}),
]);
const status = toStatus(upstreamCompare.aheadBy, forkCompare.aheadBy);
const openForkPullRequestCount = forkPulls.filter(
(pull) => pull.state === 'open',
).length;
const openUpstreamPullRequestCount = upstreamPulls.filter(
(pull) => pull.state === 'open',
).length;
const now = Date.now();
await Promise.all([
ctx.runMutation(internal.spoonState.upsert, {
spoonId,
ownerId,
upstreamFullName: upstreamRepo.full_name,
forkFullName: forkRepoData.full_name,
upstreamDefaultBranch: upstreamRepo.default_branch,
forkDefaultBranch: forkRepoData.default_branch,
upstreamHeadSha: upstreamCompare.headSha,
forkHeadSha: forkCompare.headSha,
mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
status,
openForkPullRequestCount,
openUpstreamPullRequestCount,
lastCommitAt:
getLastCommitAt(upstreamCompare) ?? getLastCommitAt(forkCompare),
rawCompareUrl: upstreamCompare.htmlUrl,
}),
ctx.runMutation(internal.spoonCommits.replaceForSpoon, {
spoonId,
ownerId,
side: 'upstream',
commits: upstreamCompare.commits,
}),
ctx.runMutation(internal.spoonCommits.replaceForSpoon, {
spoonId,
ownerId,
side: 'fork',
commits: forkCompare.commits,
}),
ctx.runMutation(internal.spoonPullRequests.replaceForSpoon, {
spoonId,
ownerId,
scope: 'fork',
pullRequests: forkPulls,
}),
ctx.runMutation(internal.spoonPullRequests.replaceForSpoon, {
spoonId,
ownerId,
scope: 'from_fork_to_upstream',
pullRequests: upstreamPulls,
}),
]);
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: status,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
lastMergeBaseCommit:
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
lastUpstreamCommit: upstreamCompare.headSha,
lastForkCommit: forkCompare.headSha,
lastGithubRefreshAt: now,
lastSuccessfulRefreshAt: now,
lastCheckedAt: now,
lastError: '',
});
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: status === 'diverged' ? 'needs_review' : 'clean',
summary: `GitHub refresh complete: ${upstreamCompare.aheadBy} upstream commit(s), ${forkCompare.aheadBy} fork-only commit(s).`,
});
return {
success: true,
status,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: 'error',
lastGithubRefreshAt: Date.now(),
lastCheckedAt: Date.now(),
lastError: message,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error: message,
}),
]);
throw new ConvexError(message);
}
};
export const refreshSpoonGithubState = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}> => {
const ownerId = await getRequiredUserId(ctx);
return await refreshOwnedSpoon(ctx, ownerId, spoonId);
},
});
export const syncForkWithUpstream = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}> => {
const ownerId = await getRequiredUserId(ctx);
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
const state = await ctx.runQuery(internal.spoonState.getInternal, {
spoonId,
ownerId,
});
if (state?.status !== 'behind' || state.forkAheadBy !== 0) {
throw new ConvexError(
'Sync is only available for behind, non-diverged forks.',
);
}
const connection = await ctx.runQuery(
internal.github.getConnectionForUser,
{
userId: ownerId,
},
);
const installationId = getSpoonInstallationId(spoon, connection);
const { forkOwner, forkRepo, forkBranch } = ensureForkMetadata(spoon);
const syncRunId = await ctx.runMutation(internal.syncRuns.createInternal, {
spoonId,
ownerId,
kind: 'merge_attempt',
status: 'running',
summary: 'Syncing fork branch with upstream.',
});
try {
const octokit = getInstallationOctokit(installationId);
await syncForkBranch(octokit, {
forkOwner,
forkRepo,
branch: forkBranch,
});
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'merged',
summary: 'GitHub fork sync completed.',
});
return await refreshOwnedSpoon(ctx, ownerId, spoonId, 'manual_check');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const conflict = message.toLowerCase().includes('conflict');
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: conflict ? 'conflict' : 'failed',
error: message,
});
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: conflict ? 'conflict' : 'error',
lastError: message,
});
throw new ConvexError(message);
}
},
});
export const refreshDueSpoons = internalAction({
args: { limit: v.optional(v.number()) },
handler: async (
ctx,
{ limit },
): Promise<
(
| {
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}
| { success: false; spoonId: Id<'spoons'>; error: string }
)[]
> => {
const due: { spoonId: Id<'spoons'>; ownerId: Id<'users'> }[] =
await ctx.runQuery(internal.spoonSettings.listRefreshDue, {
limit: limit ?? 10,
});
const results: (
| {
success: boolean;
status: ReturnType<typeof toStatus>;
upstreamAheadBy: number;
forkAheadBy: number;
}
| { success: false; spoonId: Id<'spoons'>; error: string }
)[] = [];
for (const item of due) {
try {
results.push(
await refreshOwnedSpoon(
ctx,
item.ownerId,
item.spoonId,
'scheduled_check',
),
);
} catch (error) {
results.push({
success: false,
spoonId: item.spoonId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
},
});
+12
View File
@@ -5,6 +5,18 @@ declare const process: {
readonly USESEND_API_KEY?: string;
readonly USESEND_URL?: string;
readonly USESEND_FROM_EMAIL?: string;
readonly AUTH_GITHUB_ID?: string;
readonly AUTH_GITHUB_SECRET?: string;
readonly GITHUB_APP_ID?: string;
readonly GITHUB_APP_CLIENT_ID?: string;
readonly GITHUB_APP_CLIENT_SECRET?: string;
readonly GITHUB_APP_PRIVATE_KEY?: string;
readonly GITHUB_APP_WEBHOOK_SECRET?: string;
readonly GITHUB_APP_SLUG?: string;
readonly GITHUB_APP_INSTALLATION_ID?: string;
readonly GITHUB_APP_OWNER?: string;
readonly SPOON_ENCRYPTION_KEY?: string;
readonly SPOON_WORKER_TOKEN?: string;
readonly [key: string]: string | undefined;
};
};
+15
View File
@@ -0,0 +1,15 @@
import { query } from './_generated/server';
import { getRequiredUserId } from './model';
export const getStatus = query({
args: {},
handler: async (ctx) => {
await getRequiredUserId(ctx);
return {
encryptionConfigured: Boolean(
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
process.env.INSTANCE_SECRET?.trim(),
),
};
},
});
+160
View File
@@ -0,0 +1,160 @@
import { ConvexError } from 'convex/values';
import OpenAI from 'openai';
type ReviewRisk = 'low' | 'medium' | 'high';
type ReviewAction = 'sync' | 'open_review_pr' | 'manual_review' | 'do_not_sync';
export type AiCompatibilityReview = {
summary: string;
risk: ReviewRisk;
compatible: boolean;
requiresHumanReview: boolean;
recommendedAction: ReviewAction;
potentialConflicts: string[];
importantFiles: string[];
reasoningSummary: string;
};
export type ReviewInput = {
spoonName: string;
upstreamFullName: string;
forkFullName: string;
status: string;
upstreamAheadBy: number;
forkAheadBy: number;
upstreamCommits: {
sha: string;
message: string;
authorName?: string;
committedAt?: number;
}[];
forkCommits: {
sha: string;
message: string;
authorName?: string;
committedAt?: number;
}[];
importantFilePatterns?: string[];
ignoredFilePatterns?: string[];
};
export type ReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh';
export type OpenAiReviewSettings = {
apiKey: string;
model: string;
reasoningEffort: ReasoningEffort;
};
const reviewSchema = {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'string' },
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
compatible: { type: 'boolean' },
requiresHumanReview: { type: 'boolean' },
recommendedAction: {
type: 'string',
enum: ['sync', 'open_review_pr', 'manual_review', 'do_not_sync'],
},
potentialConflicts: { type: 'array', items: { type: 'string' } },
importantFiles: { type: 'array', items: { type: 'string' } },
reasoningSummary: { type: 'string' },
},
required: [
'summary',
'risk',
'compatible',
'requiresHumanReview',
'recommendedAction',
'potentialConflicts',
'importantFiles',
'reasoningSummary',
],
} as const;
const isReviewRisk = (value: unknown): value is ReviewRisk =>
value === 'low' || value === 'medium' || value === 'high';
const isReviewAction = (value: unknown): value is ReviewAction =>
value === 'sync' ||
value === 'open_review_pr' ||
value === 'manual_review' ||
value === 'do_not_sync';
const validateReview = (value: unknown): AiCompatibilityReview => {
if (!value || typeof value !== 'object') {
throw new ConvexError('OpenAI returned an invalid review payload.');
}
const record = value as Record<string, unknown>;
if (
typeof record.summary !== 'string' ||
!isReviewRisk(record.risk) ||
typeof record.compatible !== 'boolean' ||
typeof record.requiresHumanReview !== 'boolean' ||
!isReviewAction(record.recommendedAction) ||
!Array.isArray(record.potentialConflicts) ||
!Array.isArray(record.importantFiles) ||
typeof record.reasoningSummary !== 'string'
) {
throw new ConvexError('OpenAI review did not match the expected schema.');
}
return {
summary: record.summary,
risk: record.risk,
compatible: record.compatible,
requiresHumanReview: record.requiresHumanReview,
recommendedAction: record.recommendedAction,
potentialConflicts: record.potentialConflicts.filter(
(item): item is string => typeof item === 'string',
),
importantFiles: record.importantFiles.filter(
(item): item is string => typeof item === 'string',
),
reasoningSummary: record.reasoningSummary,
};
};
export const reviewUpstreamCompatibility = async (
input: ReviewInput,
settings: OpenAiReviewSettings,
): Promise<AiCompatibilityReview> => {
const response = await new OpenAI({
apiKey: settings.apiKey,
}).responses.create({
model: settings.model,
store: false,
reasoning: {
effort: settings.reasoningEffort,
},
input: [
{
role: 'system',
content:
'You are reviewing whether upstream changes can be safely brought into a maintained fork. You do not execute code. You do not claim tests passed. Treat fork-only commits as user customizations that must be preserved. If changed files overlap with fork-only changes, increase risk. If patch context is incomplete, say so and require human review. Prefer conservative recommendations. Return only the required structured output.',
},
{
role: 'user',
content: JSON.stringify(input, null, 2),
},
],
text: {
format: {
type: 'json_schema',
name: 'spoon_upstream_compatibility_review',
strict: true,
schema: reviewSchema,
},
},
});
const raw = response.output_text;
if (!raw) throw new ConvexError('OpenAI returned an empty review.');
return validateReview(JSON.parse(raw));
};
+356
View File
@@ -104,6 +104,30 @@ const applicationTables = {
lastCheckedAt: v.optional(v.number()),
lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()),
connectionId: v.optional(v.id('gitConnections')),
githubInstallationId: v.optional(v.string()),
githubRepositoryId: v.optional(v.number()),
upstreamRepositoryId: v.optional(v.number()),
syncStatus: v.optional(
v.union(
v.literal('unknown'),
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('checking'),
v.literal('conflict'),
v.literal('error'),
),
),
upstreamAheadBy: v.optional(v.number()),
forkAheadBy: v.optional(v.number()),
lastMergeBaseCommit: v.optional(v.string()),
lastSyncRunId: v.optional(v.id('syncRuns')),
lastAiReviewId: v.optional(v.id('aiReviews')),
lastGithubRefreshAt: v.optional(v.number()),
lastSuccessfulRefreshAt: v.optional(v.number()),
lastError: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
@@ -111,6 +135,87 @@ const applicationTables = {
.index('by_owner_status', ['ownerId', 'status'])
.index('by_owner_provider', ['ownerId', 'provider'])
.index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']),
spoonRepositoryStates: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFullName: v.string(),
forkFullName: v.string(),
upstreamDefaultBranch: v.string(),
forkDefaultBranch: v.string(),
upstreamHeadSha: v.optional(v.string()),
forkHeadSha: v.optional(v.string()),
mergeBaseSha: v.optional(v.string()),
upstreamAheadBy: v.number(),
forkAheadBy: v.number(),
status: v.union(
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('unknown'),
),
openForkPullRequestCount: v.number(),
openUpstreamPullRequestCount: v.number(),
lastCommitAt: v.optional(v.number()),
rawCompareUrl: v.optional(v.string()),
refreshedAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_status', ['ownerId', 'status']),
spoonCommits: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
sha: v.string(),
side: v.union(v.literal('upstream'), v.literal('fork')),
message: v.string(),
authorName: v.optional(v.string()),
authorEmail: v.optional(v.string()),
authorLogin: v.optional(v.string()),
committedAt: v.optional(v.number()),
htmlUrl: v.optional(v.string()),
filesChanged: v.optional(v.number()),
additions: v.optional(v.number()),
deletions: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon_side', ['spoonId', 'side'])
.index('by_owner', ['ownerId'])
.index('by_sha', ['spoonId', 'sha'])
.index('by_committed', ['spoonId', 'committedAt']),
spoonPullRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
githubId: v.number(),
number: v.number(),
repoFullName: v.string(),
scope: v.union(
v.literal('fork'),
v.literal('upstream'),
v.literal('from_fork_to_upstream'),
),
title: v.string(),
state: v.union(v.literal('open'), v.literal('closed'), v.literal('merged')),
draft: v.boolean(),
authorLogin: v.optional(v.string()),
baseRef: v.string(),
headRef: v.string(),
headRepoFullName: v.optional(v.string()),
htmlUrl: v.string(),
createdAtGithub: v.optional(v.number()),
updatedAtGithub: v.optional(v.number()),
mergedAtGithub: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_spoon_scope', ['spoonId', 'scope'])
.index('by_owner', ['ownerId'])
.index('by_github_id', ['githubId'])
.index('by_state', ['spoonId', 'state']),
syncRuns: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
@@ -143,10 +248,113 @@ const applicationTables = {
.index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']),
aiReviews: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
syncRunId: v.optional(v.id('syncRuns')),
model: v.string(),
status: v.union(
v.literal('queued'),
v.literal('running'),
v.literal('completed'),
v.literal('failed'),
),
reviewType: v.union(
v.literal('upstream_update'),
v.literal('manual_prompt'),
v.literal('merge_safety'),
),
inputSummary: v.string(),
outputSummary: v.optional(v.string()),
risk: v.union(
v.literal('unknown'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
),
compatible: v.boolean(),
requiresHumanReview: v.boolean(),
recommendedAction: v.union(
v.literal('sync'),
v.literal('open_review_pr'),
v.literal('manual_review'),
v.literal('do_not_sync'),
v.literal('unknown'),
),
potentialConflicts: v.optional(v.array(v.string())),
importantFiles: v.optional(v.array(v.string())),
reasoningSummary: v.optional(v.string()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_status', ['ownerId', 'status'])
.index('by_sync_run', ['syncRunId'])
.index('by_created', ['createdAt']),
spoonSettings: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
autoRefreshEnabled: v.boolean(),
autoReviewEnabled: v.boolean(),
autoSyncEnabled: v.boolean(),
requireAiLowRiskForSync: v.boolean(),
requireCleanCompareForSync: v.boolean(),
ignoredFilePatterns: v.optional(v.array(v.string())),
importantFilePatterns: v.optional(v.array(v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
spoonRemotes: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
label: v.string(),
url: v.string(),
remoteName: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
userAiSettings: defineTable({
userId: v.id('users'),
provider: v.literal('openai'),
encryptedApiKey: v.optional(v.string()),
apiKeyPreview: v.optional(v.string()),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider']),
agentRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
agentJobId: v.optional(v.id('agentJobs')),
prompt: v.string(),
requestType: v.optional(
v.union(
v.literal('manual_prompt'),
v.literal('upstream_review'),
v.literal('future_code_change'),
),
),
priority: v.optional(
v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
),
source: v.optional(v.union(v.literal('user'), v.literal('system'))),
status: v.union(
v.literal('draft'),
v.literal('queued'),
@@ -157,6 +365,9 @@ const applicationTables = {
v.literal('cancelled'),
),
targetBranch: v.optional(v.string()),
selectedSecretIds: v.optional(v.array(v.id('spoonSecrets'))),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
summary: v.optional(v.string()),
error: v.optional(v.string()),
@@ -167,6 +378,151 @@ const applicationTables = {
.index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']),
spoonSecrets: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
name: v.string(),
encryptedValue: v.string(),
valuePreview: v.optional(v.string()),
description: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_name', ['spoonId', 'name']),
spoonAgentSettings: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
enabled: v.boolean(),
defaultBaseBranch: v.optional(v.string()),
branchPrefix: v.string(),
installCommand: v.optional(v.string()),
checkCommand: v.optional(v.string()),
testCommand: v.optional(v.string()),
agentModel: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
maxJobDurationMs: v.number(),
maxOutputBytes: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
agentJobs: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
agentRequestId: v.id('agentRequests'),
status: v.union(
v.literal('queued'),
v.literal('claimed'),
v.literal('preparing'),
v.literal('running'),
v.literal('checks_running'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('failed'),
v.literal('cancelled'),
v.literal('timed_out'),
),
prompt: v.string(),
baseBranch: v.string(),
workBranch: v.string(),
githubInstallationId: v.optional(v.string()),
forkOwner: v.string(),
forkRepo: v.string(),
forkUrl: v.string(),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
selectedSecretIds: v.array(v.id('spoonSecrets')),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
commitSha: v.optional(v.string()),
pullRequestUrl: v.optional(v.string()),
pullRequestNumber: v.optional(v.number()),
summary: v.optional(v.string()),
error: v.optional(v.string()),
claimedBy: v.optional(v.string()),
claimedAt: v.optional(v.number()),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_request', ['agentRequestId'])
.index('by_status', ['status'])
.index('by_claim', ['status', 'createdAt']),
agentJobEvents: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
level: v.union(
v.literal('debug'),
v.literal('info'),
v.literal('warn'),
v.literal('error'),
),
phase: v.union(
v.literal('queued'),
v.literal('clone'),
v.literal('plan'),
v.literal('edit'),
v.literal('install'),
v.literal('check'),
v.literal('test'),
v.literal('commit'),
v.literal('push'),
v.literal('pr'),
v.literal('cleanup'),
),
message: v.string(),
metadata: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
agentJobArtifacts: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
kind: v.union(
v.literal('plan'),
v.literal('diff'),
v.literal('test_output'),
v.literal('summary'),
v.literal('error'),
v.literal('pr_body'),
),
title: v.string(),
content: v.string(),
contentType: v.union(
v.literal('text/markdown'),
v.literal('text/plain'),
v.literal('application/json'),
v.literal('text/x-diff'),
),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
};
export default defineSchema({
+56
View File
@@ -0,0 +1,56 @@
'use node';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { ConvexError } from 'convex/values';
const getSecret = () => {
const secret =
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
process.env.INSTANCE_SECRET?.trim();
if (!secret) {
throw new ConvexError(
'SPOON_ENCRYPTION_KEY is not configured. Add it before storing user API keys.',
);
}
return secret;
};
const getKey = () => createHash('sha256').update(getSecret()).digest();
export const encryptSecret = (plaintext: string) => {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return [
iv.toString('base64url'),
tag.toString('base64url'),
ciphertext.toString('base64url'),
].join('.');
};
export const decryptSecret = (encrypted: string) => {
const [ivRaw, tagRaw, ciphertextRaw] = encrypted.split('.');
if (!ivRaw || !tagRaw || !ciphertextRaw) {
throw new ConvexError('Stored secret has an invalid format.');
}
const decipher = createDecipheriv(
'aes-256-gcm',
getKey(),
Buffer.from(ivRaw, 'base64url'),
);
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextRaw, 'base64url')),
decipher.final(),
]);
return plaintext.toString('utf8');
};
@@ -0,0 +1,118 @@
import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
const defaults = {
enabled: true,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high' as const,
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
};
export const getForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
const settings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
return (
settings ?? {
spoonId,
ownerId,
...defaults,
defaultBaseBranch:
spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
}
);
},
});
export const update = mutation({
args: {
spoonId: v.id('spoons'),
enabled: v.optional(v.boolean()),
defaultBaseBranch: v.optional(v.string()),
branchPrefix: v.optional(v.string()),
installCommand: v.optional(v.string()),
checkCommand: v.optional(v.string()),
testCommand: v.optional(v.string()),
agentModel: v.optional(v.string()),
reasoningEffort: v.optional(reasoningEffort),
maxJobDurationMs: v.optional(v.number()),
maxOutputBytes: v.optional(v.number()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, args.spoonId, ownerId);
const now = Date.now();
let settings = await ctx.db
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.first();
if (!settings) {
const id = await ctx.db.insert('spoonAgentSettings', {
spoonId: args.spoonId,
ownerId,
...defaults,
defaultBaseBranch:
spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
createdAt: now,
updatedAt: now,
});
settings = await ctx.db.get(id);
}
if (!settings) throw new Error('Agent settings not found.');
const patch: Partial<Doc<'spoonAgentSettings'>> = { updatedAt: now };
if (args.enabled !== undefined) patch.enabled = args.enabled;
if (args.defaultBaseBranch !== undefined) {
patch.defaultBaseBranch = optionalText(args.defaultBaseBranch);
}
if (args.branchPrefix !== undefined) {
patch.branchPrefix =
optionalText(args.branchPrefix) ?? defaults.branchPrefix;
}
if (args.installCommand !== undefined) {
patch.installCommand = optionalText(args.installCommand);
}
if (args.checkCommand !== undefined) {
patch.checkCommand = optionalText(args.checkCommand);
}
if (args.testCommand !== undefined) {
patch.testCommand = optionalText(args.testCommand);
}
if (args.agentModel !== undefined) {
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
}
if (args.reasoningEffort !== undefined) {
patch.reasoningEffort = args.reasoningEffort;
}
if (args.maxJobDurationMs !== undefined) {
patch.maxJobDurationMs = Math.max(60_000, args.maxJobDurationMs);
}
if (args.maxOutputBytes !== undefined) {
patch.maxOutputBytes = Math.max(10_000, args.maxOutputBytes);
}
await ctx.db.patch(settings._id, patch);
return { success: true };
},
});
+99
View File
@@ -0,0 +1,99 @@
import { v } from 'convex/values';
import { internalMutation, internalQuery, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const side = v.union(v.literal('upstream'), v.literal('fork'));
export const listForSpoon = query({
args: {
spoonId: v.id('spoons'),
side: v.optional(side),
limit: v.optional(v.number()),
},
handler: async (ctx, { spoonId, side, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
if (side) {
return await ctx.db
.query('spoonCommits')
.withIndex('by_spoon_side', (q) =>
q.eq('spoonId', spoonId).eq('side', side),
)
.order('desc')
.take(limit ?? 100);
}
const commits = await ctx.db
.query('spoonCommits')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.collect();
return commits
.filter((commit) => commit.spoonId === spoonId)
.slice(0, limit ?? 100);
},
});
export const listInternal = internalQuery({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
side,
limit: v.optional(v.number()),
},
handler: async (ctx, { spoonId, ownerId, side, limit }) => {
const rows = await ctx.db
.query('spoonCommits')
.withIndex('by_spoon_side', (q) =>
q.eq('spoonId', spoonId).eq('side', side),
)
.order('desc')
.take(limit ?? 100);
return rows.filter((row) => row.ownerId === ownerId);
},
});
export const replaceForSpoon = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
side,
commits: v.array(
v.object({
sha: v.string(),
message: v.string(),
authorName: v.optional(v.string()),
authorEmail: v.optional(v.string()),
authorLogin: v.optional(v.string()),
committedAt: v.optional(v.number()),
htmlUrl: v.optional(v.string()),
filesChanged: v.optional(v.number()),
additions: v.optional(v.number()),
deletions: v.optional(v.number()),
}),
),
},
handler: async (ctx, { spoonId, ownerId, side, commits }) => {
const existing = await ctx.db
.query('spoonCommits')
.withIndex('by_spoon_side', (q) =>
q.eq('spoonId', spoonId).eq('side', side),
)
.collect();
await Promise.all(existing.map((commit) => ctx.db.delete(commit._id)));
const now = Date.now();
await Promise.all(
commits.map((commit) =>
ctx.db.insert('spoonCommits', {
spoonId,
ownerId,
side,
...commit,
createdAt: now,
updatedAt: now,
}),
),
);
return { success: true };
},
});
@@ -0,0 +1,97 @@
import { v } from 'convex/values';
import { internalMutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const scope = v.union(
v.literal('fork'),
v.literal('upstream'),
v.literal('from_fork_to_upstream'),
);
const state = v.union(
v.literal('open'),
v.literal('closed'),
v.literal('merged'),
);
export const listForSpoon = query({
args: {
spoonId: v.id('spoons'),
scope: v.optional(scope),
state: v.optional(state),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const requestedScope = args.scope;
const rows = requestedScope
? await ctx.db
.query('spoonPullRequests')
.withIndex('by_spoon_scope', (q) =>
q.eq('spoonId', args.spoonId).eq('scope', requestedScope),
)
.order('desc')
.collect()
: await ctx.db
.query('spoonPullRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.order('desc')
.collect();
return rows
.filter((row) => !args.state || row.state === args.state)
.slice(0, args.limit ?? 100);
},
});
export const replaceForSpoon = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
scope,
pullRequests: v.array(
v.object({
githubId: v.number(),
number: v.number(),
repoFullName: v.string(),
title: v.string(),
state,
draft: v.boolean(),
authorLogin: v.optional(v.string()),
baseRef: v.string(),
headRef: v.string(),
headRepoFullName: v.optional(v.string()),
htmlUrl: v.string(),
createdAtGithub: v.optional(v.number()),
updatedAtGithub: v.optional(v.number()),
mergedAtGithub: v.optional(v.number()),
}),
),
},
handler: async (ctx, { spoonId, ownerId, scope, pullRequests }) => {
const existing = await ctx.db
.query('spoonPullRequests')
.withIndex('by_spoon_scope', (q) =>
q.eq('spoonId', spoonId).eq('scope', scope),
)
.collect();
await Promise.all(
existing.map((pullRequest) => ctx.db.delete(pullRequest._id)),
);
const now = Date.now();
await Promise.all(
pullRequests.map((pullRequest) =>
ctx.db.insert('spoonPullRequests', {
spoonId,
ownerId,
scope,
...pullRequest,
createdAt: now,
updatedAt: now,
}),
),
);
return { success: true };
},
});
+58
View File
@@ -0,0 +1,58 @@
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
optionalText,
requireText,
} from './model';
export const listForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('spoonRemotes')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('asc')
.collect();
},
});
export const create = mutation({
args: {
spoonId: v.id('spoons'),
label: v.string(),
url: v.string(),
remoteName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const now = Date.now();
return await ctx.db.insert('spoonRemotes', {
spoonId: args.spoonId,
ownerId,
label: requireText(args.label, 'Remote label'),
url: requireText(args.url, 'Remote URL'),
remoteName: optionalText(args.remoteName),
createdAt: now,
updatedAt: now,
});
},
});
export const remove = mutation({
args: { remoteId: v.id('spoonRemotes') },
handler: async (ctx, { remoteId }) => {
const ownerId = await getRequiredUserId(ctx);
const remote = await ctx.db.get(remoteId);
if (remote?.ownerId !== ownerId) {
throw new ConvexError('Remote not found.');
}
await ctx.db.delete(remoteId);
return { success: true };
},
});
+104
View File
@@ -0,0 +1,104 @@
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const publicSecret = (secret: Doc<'spoonSecrets'>) => ({
_id: secret._id,
_creationTime: secret._creationTime,
spoonId: secret.spoonId,
ownerId: secret.ownerId,
name: secret.name,
valuePreview: secret.valuePreview,
description: secret.description,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt,
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
const secrets = await ctx.db
.query('spoonSecrets')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('asc')
.collect();
return secrets.map(publicSecret);
},
});
export const remove = mutation({
args: { secretId: v.id('spoonSecrets') },
handler: async (ctx, { secretId }) => {
const ownerId = await getRequiredUserId(ctx);
const secret = await ctx.db.get(secretId);
if (secret?.ownerId !== ownerId) {
throw new ConvexError('Spoon secret not found.');
}
await ctx.db.delete(secretId);
return { success: true };
},
});
export const upsertEncryptedInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
name: v.string(),
encryptedValue: v.string(),
valuePreview: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query('spoonSecrets')
.withIndex('by_name', (q) =>
q.eq('spoonId', args.spoonId).eq('name', args.name),
)
.first();
if (existing) {
if (existing.ownerId !== args.ownerId) {
throw new ConvexError('Spoon secret not found.');
}
await ctx.db.patch(existing._id, {
encryptedValue: args.encryptedValue,
valuePreview: args.valuePreview,
description: args.description,
updatedAt: now,
});
return existing._id;
}
return await ctx.db.insert('spoonSecrets', {
...args,
createdAt: now,
updatedAt: now,
});
},
});
export const patchEncryptedInternal = internalMutation({
args: {
secretId: v.id('spoonSecrets'),
ownerId: v.id('users'),
encryptedValue: v.optional(v.string()),
valuePreview: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
const secret = await ctx.db.get(args.secretId);
if (secret?.ownerId !== args.ownerId) {
throw new ConvexError('Spoon secret not found.');
}
await ctx.db.patch(args.secretId, {
encryptedValue: args.encryptedValue ?? secret.encryptedValue,
valuePreview: args.valuePreview ?? secret.valuePreview,
description: args.description,
updatedAt: Date.now(),
});
return { success: true };
},
});
@@ -0,0 +1,94 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { encryptSecret } from './secretCrypto';
const secretNamePattern = /^[A-Z_][A-Z0-9_]*$/;
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const previewSecret = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return 'empty';
if (trimmed.length <= 8) return 'configured';
return `${trimmed.slice(0, 3)}...${trimmed.slice(-3)}`;
};
const normalizeName = (name: string) => {
const normalized = name.trim().toUpperCase();
if (!secretNamePattern.test(normalized)) {
throw new ConvexError(
'Secret names must look like environment variables, for example AUTH_SECRET.',
);
}
return normalized;
};
const optionalText = (value?: string) => {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed;
};
export const create = action({
args: {
spoonId: v.id('spoons'),
name: v.string(),
value: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'spoonSecrets'>> => {
const ownerId = await getRequiredUserId(ctx);
await ctx.runQuery(internal.spoons.getOwnedForAction, {
spoonId: args.spoonId,
ownerId,
});
const value = args.value.trim();
if (!value) throw new ConvexError('Secret value is required.');
return await ctx.runMutation(
internal.spoonSecrets.upsertEncryptedInternal,
{
spoonId: args.spoonId,
ownerId,
name: normalizeName(args.name),
encryptedValue: encryptSecret(value),
valuePreview: previewSecret(value),
description: optionalText(args.description),
},
);
},
});
export const update = action({
args: {
secretId: v.id('spoonSecrets'),
value: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args): Promise<{ success: true }> => {
const ownerId = await getRequiredUserId(ctx);
const patch = args.value?.trim()
? {
encryptedValue: encryptSecret(args.value.trim()),
valuePreview: previewSecret(args.value.trim()),
}
: {};
await ctx.runMutation(internal.spoonSecrets.patchEncryptedInternal, {
secretId: args.secretId,
ownerId,
description: optionalText(args.description),
...patch,
});
return { success: true };
},
});
+154
View File
@@ -0,0 +1,154 @@
import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const defaultSettings = {
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [] as string[],
importantFilePatterns: [] as string[],
};
export const getForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return (
(await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first()) ?? { spoonId, ownerId, ...defaultSettings }
);
},
});
export const ensureForSpoon = internalMutation({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
const existing = await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
if (existing) return existing._id;
const now = Date.now();
return await ctx.db.insert('spoonSettings', {
spoonId,
ownerId,
...defaultSettings,
createdAt: now,
updatedAt: now,
});
},
});
export const getInternal = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
const settings = await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
if (settings && settings.ownerId !== ownerId) {
throw new Error('Spoon settings ownership mismatch.');
}
return settings;
},
});
export const listRefreshDue = internalQuery({
args: { limit: v.number() },
handler: async (ctx, { limit }) => {
const now = Date.now();
const settings = await ctx.db.query('spoonSettings').collect();
const due = [];
for (const item of settings) {
if (!item.autoRefreshEnabled) continue;
const spoon = await ctx.db.get(item.spoonId);
if (
!spoon ||
spoon.status === 'archived' ||
spoon.provider !== 'github'
) {
continue;
}
if (spoon.syncCadence === 'manual') continue;
const cadenceMs =
spoon.syncCadence === 'weekly'
? 7 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
const last = spoon.lastGithubRefreshAt ?? spoon.lastCheckedAt ?? 0;
if (now - last >= cadenceMs) {
due.push({ spoonId: item.spoonId, ownerId: item.ownerId });
}
if (due.length >= limit) break;
}
return due;
},
});
export const update = mutation({
args: {
spoonId: v.id('spoons'),
autoRefreshEnabled: v.optional(v.boolean()),
autoReviewEnabled: v.optional(v.boolean()),
autoSyncEnabled: v.optional(v.boolean()),
requireAiLowRiskForSync: v.optional(v.boolean()),
requireCleanCompareForSync: v.optional(v.boolean()),
ignoredFilePatterns: v.optional(v.array(v.string())),
importantFilePatterns: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
let settings = await ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.first();
if (!settings) {
const id = await ctx.db.insert('spoonSettings', {
spoonId: args.spoonId,
ownerId,
...defaultSettings,
createdAt: Date.now(),
updatedAt: Date.now(),
});
settings = await ctx.db.get(id);
}
const patch: Partial<Doc<'spoonSettings'>> = { updatedAt: Date.now() };
if (args.autoRefreshEnabled !== undefined) {
patch.autoRefreshEnabled = args.autoRefreshEnabled;
}
if (args.autoReviewEnabled !== undefined) {
patch.autoReviewEnabled = args.autoReviewEnabled;
}
if (args.autoSyncEnabled !== undefined) {
patch.autoSyncEnabled = args.autoSyncEnabled;
}
if (args.requireAiLowRiskForSync !== undefined) {
patch.requireAiLowRiskForSync = args.requireAiLowRiskForSync;
}
if (args.requireCleanCompareForSync !== undefined) {
patch.requireCleanCompareForSync = args.requireCleanCompareForSync;
}
if (args.ignoredFilePatterns !== undefined) {
patch.ignoredFilePatterns = args.ignoredFilePatterns;
}
if (args.importantFilePatterns !== undefined) {
patch.importantFilePatterns = args.importantFilePatterns;
}
if (!settings) throw new Error('Spoon settings not found.');
await ctx.db.patch(settings._id, patch);
return { success: true };
},
});
+123
View File
@@ -0,0 +1,123 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, internalQuery, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const syncStatus = v.union(
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('unknown'),
);
export const deriveSyncStatus = ({
upstreamAheadBy,
forkAheadBy,
}: {
upstreamAheadBy: number;
forkAheadBy: number;
}): Doc<'spoonRepositoryStates'>['status'] => {
if (upstreamAheadBy === 0 && forkAheadBy === 0) return 'up_to_date';
if (upstreamAheadBy > 0 && forkAheadBy === 0) return 'behind';
if (upstreamAheadBy === 0 && forkAheadBy > 0) return 'ahead';
if (upstreamAheadBy > 0 && forkAheadBy > 0) return 'diverged';
return 'unknown';
};
export const getForSpoon = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
},
});
export const listForOwner = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
return await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
},
});
export const getInternal = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
const state = await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first();
if (state && state.ownerId !== ownerId) {
throw new ConvexError('Repository state ownership mismatch.');
}
return state;
},
});
export const upsert = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFullName: v.string(),
forkFullName: v.string(),
upstreamDefaultBranch: v.string(),
forkDefaultBranch: v.string(),
upstreamHeadSha: v.optional(v.string()),
forkHeadSha: v.optional(v.string()),
mergeBaseSha: v.optional(v.string()),
upstreamAheadBy: v.number(),
forkAheadBy: v.number(),
status: syncStatus,
openForkPullRequestCount: v.number(),
openUpstreamPullRequestCount: v.number(),
lastCommitAt: v.optional(v.number()),
rawCompareUrl: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'spoonRepositoryStates'>> => {
const now = Date.now();
const existing = await ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.first();
const patch = {
ownerId: args.ownerId,
upstreamFullName: args.upstreamFullName,
forkFullName: args.forkFullName,
upstreamDefaultBranch: args.upstreamDefaultBranch,
forkDefaultBranch: args.forkDefaultBranch,
upstreamHeadSha: args.upstreamHeadSha,
forkHeadSha: args.forkHeadSha,
mergeBaseSha: args.mergeBaseSha,
upstreamAheadBy: args.upstreamAheadBy,
forkAheadBy: args.forkAheadBy,
status: args.status,
openForkPullRequestCount: args.openForkPullRequestCount,
openUpstreamPullRequestCount: args.openUpstreamPullRequestCount,
lastCommitAt: args.lastCommitAt,
rawCompareUrl: args.rawCompareUrl,
refreshedAt: now,
updatedAt: now,
};
if (existing) {
if (existing.ownerId !== args.ownerId) {
throw new ConvexError('Repository state ownership mismatch.');
}
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('spoonRepositoryStates', {
spoonId: args.spoonId,
...patch,
createdAt: now,
});
},
});
+140 -3
View File
@@ -1,7 +1,12 @@
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
@@ -49,6 +54,17 @@ const spoonStatus = v.union(
v.literal('archived'),
);
const spoonSyncStatus = v.union(
v.literal('unknown'),
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('checking'),
v.literal('conflict'),
v.literal('error'),
);
const hasForkMetadata = (args: {
forkOwner?: string;
forkRepo?: string;
@@ -79,6 +95,48 @@ export const get = query({
},
});
export const getDetails = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
const [state, settings, latestReview, recentRuns, agentRequests] =
await Promise.all([
ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first(),
ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first(),
ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.first(),
ctx.db
.query('syncRuns')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
ctx.db
.query('agentRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
]);
return { spoon, state, settings, latestReview, recentRuns, agentRequests };
},
});
export const getOwnedForAction = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
return await getOwnedSpoon(ctx, spoonId, ownerId);
},
});
export const createManual = mutation({
args: {
name: v.string(),
@@ -103,12 +161,13 @@ export const createManual = mutation({
const now = Date.now();
const forkOwner = optionalText(args.forkOwner);
const forkRepo = optionalText(args.forkRepo);
const forkDefaultBranch = optionalText(args.forkDefaultBranch);
const forkUrl = optionalText(args.forkUrl);
const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl })
? 'draft'
: 'needs_connection';
return await ctx.db.insert('spoons', {
const spoonId = await ctx.db.insert('spoons', {
ownerId,
name: requireText(args.name, 'Spoon name'),
description: optionalText(args.description),
@@ -122,7 +181,7 @@ export const createManual = mutation({
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
forkOwner,
forkRepo,
forkDefaultBranch: optionalText(args.forkDefaultBranch),
forkDefaultBranch,
forkUrl,
visibility: args.visibility,
maintenanceMode: args.maintenanceMode,
@@ -130,9 +189,87 @@ export const createManual = mutation({
productionRefStrategy: args.productionRefStrategy,
tagPattern: optionalText(args.tagPattern),
status,
syncStatus: 'unknown',
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonSettings', {
spoonId,
ownerId,
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [],
importantFilePatterns: [],
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonAgentSettings', {
spoonId,
ownerId,
enabled: true,
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high',
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
createdAt: now,
updatedAt: now,
});
return spoonId;
},
});
export const patchSyncFields = internalMutation({
args: {
spoonId: v.id('spoons'),
syncStatus: v.optional(spoonSyncStatus),
upstreamAheadBy: v.optional(v.number()),
forkAheadBy: v.optional(v.number()),
lastMergeBaseCommit: v.optional(v.string()),
lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()),
lastSyncRunId: v.optional(v.id('syncRuns')),
lastAiReviewId: v.optional(v.id('aiReviews')),
lastGithubRefreshAt: v.optional(v.number()),
lastSuccessfulRefreshAt: v.optional(v.number()),
lastCheckedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
},
handler: async (ctx, args) => {
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
if (args.syncStatus !== undefined) patch.syncStatus = args.syncStatus;
if (args.upstreamAheadBy !== undefined) {
patch.upstreamAheadBy = args.upstreamAheadBy;
}
if (args.forkAheadBy !== undefined) patch.forkAheadBy = args.forkAheadBy;
if (args.lastMergeBaseCommit !== undefined) {
patch.lastMergeBaseCommit = args.lastMergeBaseCommit;
}
if (args.lastUpstreamCommit !== undefined) {
patch.lastUpstreamCommit = args.lastUpstreamCommit;
}
if (args.lastForkCommit !== undefined) {
patch.lastForkCommit = args.lastForkCommit;
}
if (args.lastSyncRunId !== undefined)
patch.lastSyncRunId = args.lastSyncRunId;
if (args.lastAiReviewId !== undefined)
patch.lastAiReviewId = args.lastAiReviewId;
if (args.lastGithubRefreshAt !== undefined) {
patch.lastGithubRefreshAt = args.lastGithubRefreshAt;
}
if (args.lastSuccessfulRefreshAt !== undefined) {
patch.lastSuccessfulRefreshAt = args.lastSuccessfulRefreshAt;
}
if (args.lastCheckedAt !== undefined)
patch.lastCheckedAt = args.lastCheckedAt;
if (args.lastError !== undefined) patch.lastError = args.lastError;
await ctx.db.patch(args.spoonId, patch);
return { success: true };
},
});
+72 -1
View File
@@ -1,8 +1,27 @@
import { v } from 'convex/values';
import { query } from './_generated/server';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
const syncKind = v.union(
v.literal('scheduled_check'),
v.literal('manual_check'),
v.literal('upstream_update'),
v.literal('merge_attempt'),
v.literal('ai_review'),
);
const syncStatus = v.union(
v.literal('queued'),
v.literal('running'),
v.literal('clean'),
v.literal('conflict'),
v.literal('needs_review'),
v.literal('failed'),
v.literal('merged'),
);
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
@@ -28,3 +47,55 @@ export const listForSpoon = query({
.take(limit ?? 25);
},
});
export const createInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
kind: syncKind,
status: syncStatus,
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
summary: v.optional(v.string()),
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'syncRuns'>> => {
const now = Date.now();
return await ctx.db.insert('syncRuns', {
...args,
createdAt: now,
updatedAt: now,
});
},
});
export const patchInternal = internalMutation({
args: {
syncRunId: v.id('syncRuns'),
status: v.optional(syncStatus),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
summary: v.optional(v.string()),
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const patch: Partial<Doc<'syncRuns'>> = { updatedAt: Date.now() };
if (args.status !== undefined) patch.status = args.status;
if (args.upstreamFrom !== undefined) patch.upstreamFrom = args.upstreamFrom;
if (args.upstreamTo !== undefined) patch.upstreamTo = args.upstreamTo;
if (args.summary !== undefined) patch.summary = args.summary;
if (args.aiAssessment !== undefined) {
patch.aiAssessment = args.aiAssessment;
}
if (args.mergeRequestUrl !== undefined) {
patch.mergeRequestUrl = args.mergeRequestUrl;
}
if (args.error !== undefined) patch.error = args.error;
await ctx.db.patch(args.syncRunId, patch);
return { success: true };
},
});
+1
View File
@@ -15,6 +15,7 @@
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"],
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true