Add agent workflows & stuff
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user