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 runtime = v.literal('opencode'); const jobType = v.union( v.literal('user_change'), v.literal('maintenance_review'), v.literal('conflict_resolution'), ); const workspaceStatus = v.union( v.literal('not_started'), v.literal('starting'), v.literal('active'), v.literal('idle'), v.literal('stopped'), v.literal('expired'), v.literal('failed'), ); const messageRole = v.union( v.literal('user'), v.literal('assistant'), v.literal('system'), v.literal('tool'), ); const messageStatus = v.union( v.literal('queued'), v.literal('streaming'), v.literal('completed'), v.literal('failed'), ); const changeSource = v.union( v.literal('user'), v.literal('agent'), v.literal('command'), ); const changeType = v.union( v.literal('added'), v.literal('modified'), v.literal('deleted'), v.literal('renamed'), ); 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 maintenanceDecision = v.union( v.literal('sync'), v.literal('ignore'), v.literal('open_review_pr'), v.literal('manual_review'), v.literal('conflict_resolution'), v.literal('unknown'), ); const maintenanceRisk = v.union( v.literal('low'), v.literal('medium'), v.literal('high'), v.literal('unknown'), ); const defaultAgentSettings = { enabled: true, runtime: 'opencode' as const, branchPrefix: 'spoon/agent', agentModel: '', reasoningEffort: 'medium' as const, maxJobDurationMs: 1_800_000, maxOutputBytes: 200_000, envFilePath: '.env.local', materializeEnvFileByDefault: false, autoDetectCommands: true, allowUserFileEditing: true, }; 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 normalizeEnvFilePath = (value?: string) => { const trimmed = optionalText(value); if (!trimmed) return undefined; if (trimmed.startsWith('/') || trimmed.includes('..')) { throw new ConvexError('Env file path must stay inside the repository.'); } if (!/^\.env(?:[./-][A-Za-z0-9_.-]+)?$/.test(trimmed)) { throw new ConvexError('Env file path must be a .env-style path.'); } return trimmed; }; 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.'); } } }; const getJobProfile = async ( ctx: MutationCtx, ownerId: Id<'users'>, profileId?: Id<'aiProviderProfiles'>, ) => { const profile = profileId ? await ctx.db.get(profileId) : await getDefaultJobProfile(ctx, ownerId); if (profile?.ownerId !== ownerId || !profile.enabled) { throw new ConvexError('AI provider profile not found.'); } if (profile.authType !== 'none' && !profile.encryptedSecret) { throw new ConvexError('Selected AI provider is missing credentials.'); } return profile; }; const getDefaultJobProfile = async (ctx: MutationCtx, ownerId: Id<'users'>) => { const profiles = await ctx.db .query('aiProviderProfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .collect(); const configuredProfiles = profiles.filter( (profile) => profile.enabled && (profile.authType === 'none' || Boolean(profile.encryptedSecret)), ); const explicitDefault = configuredProfiles.find( (profile) => (profile as Doc<'aiProviderProfiles'> & { isDefault?: boolean }) .isDefault, ); const profile = explicitDefault ?? (configuredProfiles.length === 1 ? configuredProfiles[0] : undefined); if (!profile) { throw new ConvexError( 'Choose a default AI provider before queueing agent work.', ); } return profile; }; const listSpoonSecretIds = async ( ctx: MutationCtx, spoonId: Id<'spoons'>, ownerId: Id<'users'>, ) => { const secrets = await ctx.db .query('spoonSecrets') .withIndex('by_spoon', (q) => q.eq('spoonId', spoonId)) .collect(); return secrets .filter((secret) => secret.ownerId === ownerId) .map((secret) => secret._id); }; const insertJob = async ( ctx: MutationCtx, { ownerId, spoon, requestId, prompt, settings, threadId, requestedJobType, baseBranch, requestedBranchName, requestedRuntime, materializeEnvFile, requestedEnvFilePath, requestedProfileId, }: { ownerId: Id<'users'>; spoon: Doc<'spoons'>; requestId: Id<'agentRequests'>; prompt: string; settings: Awaited>; threadId?: Id<'threads'>; requestedJobType: | 'user_change' | 'maintenance_review' | 'conflict_resolution'; baseBranch?: string; requestedBranchName?: string; requestedRuntime?: 'opencode'; materializeEnvFile?: boolean; requestedEnvFilePath?: string; requestedProfileId?: Id<'aiProviderProfiles'>; }, ) => { 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.', ); } if (!settings.enabled) { throw new ConvexError('Agent jobs are disabled for this Spoon.'); } const aiProviderProfileId = requestedProfileId ?? settings.aiProviderProfileId; const profile = await getJobProfile(ctx, ownerId, aiProviderProfileId); const selectedSecretIds = await listSpoonSecretIds(ctx, spoon._id, ownerId); const now = Date.now(); const resolvedBaseBranch = optionalText(baseBranch) ?? settings.defaultBaseBranch; const jobRuntime = requestedRuntime ?? 'opencode'; const shouldMaterializeEnvFile = materializeEnvFile ?? settings.materializeEnvFileByDefault; const envFilePath = normalizeEnvFilePath(requestedEnvFilePath) ?? normalizeEnvFilePath( settings.envFilePath === 'custom' ? settings.customEnvFilePath : settings.envFilePath, ); const workBranch = buildBranch( requestId, prompt, settings.branchPrefix, requestedBranchName, ); const jobId = await ctx.db.insert('agentJobs', { spoonId: spoon._id, ownerId, agentRequestId: requestId, threadId, jobType: requestedJobType, status: 'queued', prompt, runtime: jobRuntime, workspaceStatus: 'not_started', baseBranch: resolvedBaseBranch, workBranch, envFilePath, materializeEnvFile: shouldMaterializeEnvFile, githubInstallationId: spoon.githubInstallationId, forkOwner: spoon.forkOwner, forkRepo: spoon.forkRepo, forkUrl: spoon.forkUrl, upstreamOwner: spoon.upstreamOwner, upstreamRepo: spoon.upstreamRepo, selectedSecretIds, aiProviderProfileId: profile._id, model: profile.defaultModel, reasoningEffort: profile.reasoningEffort, createdAt: now, updatedAt: now, }); await ctx.db.patch(requestId, { agentJobId: jobId, selectedSecretIds, baseBranch: resolvedBaseBranch, requestedBranchName: optionalText(requestedBranchName), status: 'queued', updatedAt: now, }); if (threadId) { await ctx.db.patch(threadId, { latestAgentJobId: jobId, relatedAgentRequestId: requestId, status: 'queued', updatedAt: now, }); } await ctx.db.insert('agentJobEvents', { jobId, spoonId: spoon._id, ownerId, level: 'info', phase: 'queued', message: 'OpenCode job queued.', createdAt: now, }); await ctx.db.insert('agentJobMessages', { jobId, spoonId: spoon._id, ownerId, role: 'user', content: prompt, status: 'completed', createdAt: now, updatedAt: now, }); return jobId; }; 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()), runtime: v.optional(runtime), materializeEnvFile: v.optional(v.boolean()), envFilePath: v.optional(v.string()), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), }, 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); const settings = await getAgentSettings(ctx, spoon); await assertSecretOwnership( ctx, spoon._id, ownerId, args.selectedSecretIds, ); return await insertJob(ctx, { ownerId, spoon, requestId: request._id, prompt: request.prompt, settings, requestedJobType: 'user_change', baseBranch: args.baseBranch, requestedBranchName: args.requestedBranchName, requestedRuntime: args.runtime, materializeEnvFile: args.materializeEnvFile, requestedEnvFilePath: args.envFilePath, requestedProfileId: args.aiProviderProfileId, }); }, }); export const createForThread = mutation({ args: { threadId: v.id('threads'), jobType, baseBranch: v.optional(v.string()), requestedBranchName: v.optional(v.string()), materializeEnvFile: v.optional(v.boolean()), envFilePath: v.optional(v.string()), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), }, handler: async (ctx, args) => { const ownerId = await getRequiredUserId(ctx); const thread = await ctx.db.get(args.threadId); if (thread?.ownerId !== ownerId || !thread.spoonId) { throw new ConvexError('Thread not found.'); } if (thread.latestAgentJobId) { throw new ConvexError('This thread already has an agent job.'); } const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId); const promptMessage = await ctx.db .query('threadMessages') .withIndex('by_thread', (q) => q.eq('threadId', args.threadId)) .order('desc') .first(); const prompt = promptMessage?.content ?? thread.summary ?? `Work on thread: ${thread.title}`; const now = Date.now(); const requestId = await ctx.db.insert('agentRequests', { spoonId: spoon._id, ownerId, prompt, status: 'queued', requestType: args.jobType === 'user_change' ? 'future_code_change' : 'upstream_review', priority: thread.priority, source: thread.source === 'user_request' ? 'user' : 'system', targetBranch: optionalText(args.baseBranch), createdAt: now, updatedAt: now, }); const settings = await getAgentSettings(ctx, spoon); const jobId = await insertJob(ctx, { ownerId, spoon, requestId, prompt, settings, threadId: args.threadId, requestedJobType: args.jobType, baseBranch: args.baseBranch, requestedBranchName: args.requestedBranchName, materializeEnvFile: args.materializeEnvFile, requestedEnvFilePath: args.envFilePath, requestedProfileId: args.aiProviderProfileId, }); return jobId; }, }); export const createForThreadInternal = internalMutation({ args: { threadId: v.id('threads'), ownerId: v.id('users'), jobType, baseBranch: v.optional(v.string()), requestedBranchName: v.optional(v.string()), materializeEnvFile: v.optional(v.boolean()), envFilePath: v.optional(v.string()), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), }, handler: async (ctx, args) => { const thread = await ctx.db.get(args.threadId); if (thread?.ownerId !== args.ownerId || !thread.spoonId) { throw new ConvexError('Thread not found.'); } if (thread.latestAgentJobId) return thread.latestAgentJobId; const spoon = await ctx.db.get(thread.spoonId); if (spoon?.ownerId !== args.ownerId) { throw new ConvexError('Spoon not found.'); } const promptMessage = await ctx.db .query('threadMessages') .withIndex('by_thread', (q) => q.eq('threadId', args.threadId)) .order('desc') .first(); const prompt = promptMessage?.content ?? thread.summary ?? `Review maintenance thread: ${thread.title}`; const now = Date.now(); const requestId = await ctx.db.insert('agentRequests', { spoonId: spoon._id, ownerId: args.ownerId, prompt, status: 'queued', requestType: args.jobType === 'user_change' ? 'future_code_change' : 'upstream_review', priority: thread.priority, source: thread.source === 'user_request' ? 'user' : 'system', targetBranch: optionalText(args.baseBranch), createdAt: now, updatedAt: now, }); const settings = await getAgentSettings(ctx, spoon); return await insertJob(ctx, { ownerId: args.ownerId, spoon, requestId, prompt, settings, threadId: args.threadId, requestedJobType: args.jobType, baseBranch: args.baseBranch, requestedBranchName: args.requestedBranchName, materializeEnvFile: args.materializeEnvFile, requestedEnvFilePath: args.envFilePath, requestedProfileId: args.aiProviderProfileId, }); }, }); 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 assertOwned = 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, ownerId }; }, }); export const listMessages = 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('agentJobMessages') .withIndex('by_job', (q) => q.eq('jobId', jobId)) .order('asc') .take(limit ?? 200); }, }); export const appendUserMessage = mutation({ args: { jobId: v.id('agentJobs'), content: v.string() }, handler: async (ctx, { jobId, content }) => { const ownerId = await getRequiredUserId(ctx); const job = await ctx.db.get(jobId); if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.'); const trimmed = optionalText(content); if (!trimmed) throw new ConvexError('Message is required.'); const now = Date.now(); return await ctx.db.insert('agentJobMessages', { jobId, spoonId: job.spoonId, ownerId, role: 'user', content: trimmed, status: 'queued', createdAt: now, updatedAt: now, }); }, }); export const listWorkspaceChanges = 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('agentWorkspaceChanges') .withIndex('by_job', (q) => q.eq('jobId', jobId)) .order('desc') .take(limit ?? 100); }, }); 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 aiProviderProfile = job.aiProviderProfileId ? await ctx.db.get(job.aiProviderProfileId) : null; 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, aiProviderProfile: aiProviderProfile?.ownerId === job.ownerId ? aiProviderProfile : null, 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> = { 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); if (job.threadId) { const threadStatus = args.status === 'queued' || args.status === 'claimed' ? 'queued' : args.status === 'running' || args.status === 'checks_running' ? 'running' : args.status === 'changes_ready' ? 'changes_ready' : args.status === 'draft_pr_opened' ? 'draft_pr_opened' : args.status === 'failed' || args.status === 'timed_out' ? 'failed' : args.status === 'cancelled' ? 'cancelled' : undefined; if (threadStatus) { const threadPatch: Partial> = { status: threadStatus, summary: args.summary ?? job.summary, updatedAt: now, }; if ( ['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes( args.status, ) ) { threadPatch.resolvedAt = now; } await ctx.db.patch(job.threadId, threadPatch); } } return { success: true }; }, }); export const markWorkspaceActive = mutation({ args: { workerToken: v.string(), workerId: v.string(), jobId: v.id('agentJobs'), opencodeSessionId: v.optional(v.string()), containerId: v.optional(v.string()), workspaceUrl: v.optional(v.string()), workspaceExpiresAt: v.optional(v.number()), }, 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, { workspaceStatus: 'active', opencodeSessionId: optionalText(args.opencodeSessionId), containerId: optionalText(args.containerId), workspaceUrl: optionalText(args.workspaceUrl), workspaceExpiresAt: args.workspaceExpiresAt, lastHeartbeatAt: now, updatedAt: now, }); return { success: true }; }, }); export const markWorkspaceStopped = mutation({ args: { workerToken: v.string(), workerId: v.string(), jobId: v.id('agentJobs'), workspaceStatus: v.optional(workspaceStatus), }, 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, { workspaceStatus: args.workspaceStatus ?? 'stopped', updatedAt: now, }); return { success: true }; }, }); export const heartbeatWorkspace = mutation({ args: { workerToken: v.string(), workerId: v.string(), jobId: v.id('agentJobs'), }, 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.'); } await ctx.db.patch(args.jobId, { workspaceStatus: 'active', lastHeartbeatAt: Date.now(), updatedAt: Date.now(), }); 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, }); if (job.threadId) { await ctx.db.patch(job.threadId, { status: 'draft_pr_opened', summary: args.summary, updatedAt: now, resolvedAt: now, }); } return { success: true }; }, }); export const applyMaintenanceDecision = mutation({ args: { workerToken: v.string(), workerId: v.string(), jobId: v.id('agentJobs'), decision: maintenanceDecision, risk: maintenanceRisk, summary: v.string(), ignoredCommitShas: v.array(v.string()), ignoredReason: v.optional(v.string()), recommendedAction: v.string(), requiresUserApproval: v.boolean(), }, 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.'); } if (!job.threadId) return { success: true }; const now = Date.now(); const outcome = args.decision === 'sync' ? 'sync_recommended' : args.decision === 'ignore' ? 'ignored' : args.decision === 'open_review_pr' ? 'review_pr_recommended' : args.decision === 'conflict_resolution' ? 'conflict_resolution_required' : args.decision === 'manual_review' ? 'manual_review_required' : 'unknown'; const status = args.decision === 'ignore' ? 'ignored' : args.decision === 'sync' && !args.requiresUserApproval ? 'resolved' : 'waiting_for_user'; const threadPatch: Partial> = { status, maintenanceOutcome: outcome, summary: args.summary, ignoredCommitShas: args.ignoredCommitShas, ignoredReason: args.ignoredReason, updatedAt: now, }; if (status === 'ignored' || status === 'resolved') { threadPatch.resolvedAt = now; } await ctx.db.patch(job.threadId, threadPatch); if (args.decision === 'ignore' && args.ignoredCommitShas.length > 0) { const thread = await ctx.db.get(job.threadId); await ctx.db.insert('ignoredUpstreamChanges', { spoonId: job.spoonId, ownerId: job.ownerId, upstreamFrom: thread?.upstreamFrom, upstreamTo: thread?.upstreamTo ?? job.upstreamRepo, commitShas: args.ignoredCommitShas, reason: args.ignoredReason ?? args.summary, decidedBy: 'agent', threadId: job.threadId, createdAt: now, }); } await ctx.db.insert('threadMessages', { threadId: job.threadId, ownerId: job.ownerId, spoonId: job.spoonId, role: 'assistant', content: args.summary, status: 'completed', metadata: JSON.stringify({ decision: args.decision, risk: args.risk, recommendedAction: args.recommendedAction, }), createdAt: now, 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 appendMessage = mutation({ args: { workerToken: v.string(), workerId: v.string(), jobId: v.id('agentJobs'), role: messageRole, content: v.string(), status: messageStatus, 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.'); } const now = Date.now(); const messageId = await ctx.db.insert('agentJobMessages', { jobId: args.jobId, spoonId: job.spoonId, ownerId: job.ownerId, role: args.role, content: args.content, status: args.status, metadata: args.metadata, createdAt: now, updatedAt: now, }); if (job.threadId) { await ctx.db.insert('threadMessages', { threadId: job.threadId, spoonId: job.spoonId, ownerId: job.ownerId, role: args.role, content: args.content, status: args.status, metadata: args.metadata, createdAt: now, updatedAt: now, }); } return messageId; }, }); export const updateMessage = mutation({ args: { workerToken: v.string(), workerId: v.string(), messageId: v.id('agentJobMessages'), content: v.optional(v.string()), status: v.optional(messageStatus), metadata: v.optional(v.string()), }, handler: async (ctx, args) => { requireWorkerToken(args.workerToken); const message = await ctx.db.get(args.messageId); if (!message) throw new ConvexError('Agent message not found.'); const job = await ctx.db.get(message.jobId); if (job?.claimedBy !== args.workerId) { throw new ConvexError('Agent job not claimed by this worker.'); } const patch: Partial> = { updatedAt: Date.now(), }; if (args.content !== undefined) patch.content = args.content; if (args.status !== undefined) patch.status = args.status; if (args.metadata !== undefined) patch.metadata = args.metadata; await ctx.db.patch(args.messageId, patch); return { success: true }; }, }); export const recordWorkspaceChange = mutation({ args: { workerToken: v.string(), workerId: v.string(), jobId: v.id('agentJobs'), path: v.string(), source: changeSource, changeType, diff: 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('agentWorkspaceChanges', { jobId: args.jobId, spoonId: job.spoonId, ownerId: job.ownerId, path: args.path, source: args.source, changeType: args.changeType, diff: args.diff, 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(), }); }, });