import { ConvexError, v } from 'convex/values'; import type { Doc } from './_generated/dataModel'; import { internalMutation, internalQuery, mutation, query, } from './_generated/server'; import { getOwnedSpoon, getRequiredUserId, optionalText, requireText, } from './model'; const provider = v.union( v.literal('github'), v.literal('gitea'), v.literal('gitlab'), v.literal('other'), ); const visibility = v.union( v.literal('public'), v.literal('private'), v.literal('internal'), v.literal('unknown'), ); const maintenanceMode = v.union( v.literal('watch'), v.literal('auto_pr'), v.literal('paused'), ); const syncCadence = v.union( v.literal('daily'), v.literal('weekly'), v.literal('manual'), ); const productionRefStrategy = v.union( v.literal('default_branch'), v.literal('latest_release'), v.literal('tag_pattern'), ); const spoonStatus = v.union( v.literal('draft'), v.literal('active'), v.literal('needs_connection'), v.literal('paused'), 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; forkUrl?: string; }) => Boolean( args.forkOwner?.trim() && args.forkRepo?.trim() && args.forkUrl?.trim(), ); export const listMine = query({ args: {}, handler: async (ctx) => { const ownerId = await getRequiredUserId(ctx); const spoons = await ctx.db .query('spoons') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .order('desc') .collect(); return spoons.filter((spoon) => spoon.status !== 'archived'); }, }); export const get = query({ args: { spoonId: v.id('spoons') }, handler: async (ctx, { spoonId }) => { const ownerId = await getRequiredUserId(ctx); return getOwnedSpoon(ctx, spoonId, ownerId); }, }); 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(), description: v.optional(v.string()), provider, upstreamOwner: v.string(), upstreamRepo: v.string(), upstreamDefaultBranch: v.string(), upstreamUrl: v.string(), forkOwner: v.optional(v.string()), forkRepo: v.optional(v.string()), forkDefaultBranch: v.optional(v.string()), forkUrl: v.optional(v.string()), visibility, maintenanceMode, syncCadence, productionRefStrategy, tagPattern: v.optional(v.string()), }, handler: async (ctx, args) => { const ownerId = await getRequiredUserId(ctx); 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'; const spoonId = await ctx.db.insert('spoons', { ownerId, name: requireText(args.name, 'Spoon name'), description: optionalText(args.description), provider: args.provider, upstreamOwner: requireText(args.upstreamOwner, 'Upstream owner'), upstreamRepo: requireText(args.upstreamRepo, 'Upstream repository'), upstreamDefaultBranch: requireText( args.upstreamDefaultBranch, 'Upstream default branch', ), upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'), forkOwner, forkRepo, forkDefaultBranch, forkUrl, visibility: args.visibility, maintenanceMode: args.maintenanceMode, syncCadence: args.syncCadence, 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> = { 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 }; }, }); export const updateSettings = mutation({ args: { spoonId: v.id('spoons'), maintenanceMode: v.optional(maintenanceMode), syncCadence: v.optional(syncCadence), productionRefStrategy: v.optional(productionRefStrategy), tagPattern: v.optional(v.string()), status: v.optional(spoonStatus), }, handler: async (ctx, args) => { const ownerId = await getRequiredUserId(ctx); await getOwnedSpoon(ctx, args.spoonId, ownerId); const patch: Partial> = { updatedAt: Date.now() }; if (args.maintenanceMode) patch.maintenanceMode = args.maintenanceMode; if (args.syncCadence) patch.syncCadence = args.syncCadence; if (args.productionRefStrategy) { patch.productionRefStrategy = args.productionRefStrategy; } if (args.tagPattern !== undefined) patch.tagPattern = optionalText(args.tagPattern); if (args.status) patch.status = args.status; await ctx.db.patch(args.spoonId, patch); return { success: true }; }, }); export const archive = mutation({ args: { spoonId: v.id('spoons') }, handler: async (ctx, { spoonId }) => { const ownerId = await getRequiredUserId(ctx); const spoon = await getOwnedSpoon(ctx, spoonId, ownerId); if (spoon.status === 'archived') throw new ConvexError('Spoon is archived.'); await ctx.db.patch(spoonId, { status: 'archived', updatedAt: Date.now(), }); return { success: true }; }, });