From 6ec2e51312cfb0c498e49d37c663535881dcddfd Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 11:03:43 -0400 Subject: [PATCH] feat(backend): migration to retire openai_direct runtime --- packages/backend/convex/migrations.ts | 32 +++++ .../backend/tests/unit/migrations.test.ts | 114 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 packages/backend/tests/unit/migrations.test.ts diff --git a/packages/backend/convex/migrations.ts b/packages/backend/convex/migrations.ts index 8be691e..41cffb5 100644 --- a/packages/backend/convex/migrations.ts +++ b/packages/backend/convex/migrations.ts @@ -23,3 +23,35 @@ export const backfillAutoSyncDefaults = internalMutation({ return { updated }; }, }); + +// One-shot migration retiring the legacy `openai_direct` runtime literal: +// rewrites any surviving `agentJobs`/`spoonAgentSettings` rows to `opencode`. +// `openai_direct` stays in the schema unions as a legacy-read literal; nothing +// writes it anymore. Run once post-deploy via +// `npx convex run migrations:migrateOpenAiDirectRuntime`. Idempotent. +export const migrateOpenAiDirectRuntime = internalMutation({ + args: {}, + returns: v.object({ patched: v.number() }), + handler: async (ctx) => { + let patched = 0; + for (const job of await ctx.db.query('agentJobs').collect()) { + if (job.runtime === 'openai_direct') { + await ctx.db.patch(job._id, { + runtime: 'opencode', + updatedAt: Date.now(), + }); + patched += 1; + } + } + for (const settings of await ctx.db.query('spoonAgentSettings').collect()) { + if (settings.runtime === 'openai_direct') { + await ctx.db.patch(settings._id, { + runtime: 'opencode', + updatedAt: Date.now(), + }); + patched += 1; + } + } + return { patched }; + }, +}); diff --git a/packages/backend/tests/unit/migrations.test.ts b/packages/backend/tests/unit/migrations.test.ts new file mode 100644 index 0000000..cd2f939 --- /dev/null +++ b/packages/backend/tests/unit/migrations.test.ts @@ -0,0 +1,114 @@ +import { convexTest } from 'convex-test'; +import { describe, expect, test } from 'vitest'; + +import { internal } from '../../convex/_generated/api.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +describe('migrateOpenAiDirectRuntime', () => { + test('rewrites legacy openai_direct rows to opencode and leaves others', async () => { + const t = convexTest(schema, modules); + const now = Date.now(); + + const ids = await t.run(async (ctx) => { + const ownerId = await ctx.db.insert('users', { + email: 'owner@example.com', + name: 'Owner', + }); + const spoonId = await ctx.db.insert('spoons', { + ownerId, + name: 'legacy-spoon', + provider: 'gitea', + upstreamOwner: 'upstream', + upstreamRepo: 'legacy-spoon', + upstreamDefaultBranch: 'main', + upstreamUrl: 'https://example.com/upstream/legacy-spoon', + forkOwner: 'owner', + forkRepo: 'legacy-spoon', + forkUrl: 'https://example.com/owner/legacy-spoon', + visibility: 'private', + maintenanceMode: 'watch', + syncCadence: 'daily', + productionRefStrategy: 'default_branch', + status: 'active', + createdAt: now, + updatedAt: now, + }); + const agentRequestId = await ctx.db.insert('agentRequests', { + spoonId, + ownerId, + prompt: 'legacy request', + status: 'running', + createdAt: now, + updatedAt: now, + }); + + const settings = (runtime: 'openai_direct' | 'codex') => ({ + spoonId, + ownerId, + enabled: true, + runtime, + branchPrefix: 'spoon/', + agentModel: 'gpt-5', + reasoningEffort: 'medium' as const, + maxJobDurationMs: 600000, + maxOutputBytes: 1000000, + createdAt: now, + updatedAt: now, + }); + const job = (runtime: 'openai_direct' | 'codex') => ({ + spoonId, + ownerId, + agentRequestId, + status: 'running' as const, + prompt: 'legacy job', + runtime, + baseBranch: 'main', + workBranch: 'spoon/legacy', + forkOwner: 'owner', + forkRepo: 'legacy-spoon', + forkUrl: 'https://example.com/owner/legacy-spoon', + upstreamOwner: 'upstream', + upstreamRepo: 'legacy-spoon', + selectedSecretIds: [], + model: 'gpt-5', + reasoningEffort: 'medium' as const, + createdAt: now, + updatedAt: now, + }); + + return { + legacySettings: await ctx.db.insert( + 'spoonAgentSettings', + settings('openai_direct'), + ), + legacyJob: await ctx.db.insert('agentJobs', job('openai_direct')), + codexSettings: await ctx.db.insert( + 'spoonAgentSettings', + settings('codex'), + ), + codexJob: await ctx.db.insert('agentJobs', job('codex')), + }; + }); + + const result = await t.mutation( + internal.migrations.migrateOpenAiDirectRuntime, + {}, + ); + + expect(result.patched).toBe(2); + + const after = await t.run(async (ctx) => ({ + legacySettings: await ctx.db.get(ids.legacySettings), + legacyJob: await ctx.db.get(ids.legacyJob), + codexSettings: await ctx.db.get(ids.codexSettings), + codexJob: await ctx.db.get(ids.codexJob), + })); + + expect(after.legacySettings?.runtime).toBe('opencode'); + expect(after.legacyJob?.runtime).toBe('opencode'); + expect(after.codexSettings?.runtime).toBe('codex'); + expect(after.codexJob?.runtime).toBe('codex'); + }); +});