feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker

This commit is contained in:
Gabriel Brown
2026-07-11 10:58:05 -04:00
parent 70396feccc
commit c0f107c4cf
7 changed files with 250 additions and 32 deletions
+31 -6
View File
@@ -4,6 +4,8 @@ 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';
import type { AgentRuntimeName } from './runtimeSupport';
import { runtimesForProfile } from './runtimeSupport';
import { assertWorkerToken } from './workerAuth';
const jobStatus = v.union(
@@ -19,7 +21,11 @@ const jobStatus = v.union(
v.literal('timed_out'),
);
const runtime = v.literal('opencode');
const runtime = v.union(
v.literal('codex'),
v.literal('opencode'),
v.literal('claude'),
);
const jobType = v.union(
v.literal('user_change'),
@@ -141,7 +147,6 @@ const maintenanceRisk = v.union(
const defaultAgentSettings = {
enabled: true,
runtime: 'opencode' as const,
branchPrefix: 'spoon/agent',
agentModel: '',
reasoningEffort: 'medium' as const,
@@ -440,7 +445,7 @@ const insertJob = async (
| 'conflict_resolution';
baseBranch?: string;
requestedBranchName?: string;
requestedRuntime?: 'opencode';
requestedRuntime?: AgentRuntimeName;
materializeEnvFile?: boolean;
requestedEnvFilePath?: string;
requestedProfileId?: Id<'aiProviderProfiles'>;
@@ -464,7 +469,23 @@ const insertJob = async (
const now = Date.now();
const resolvedBaseBranch =
optionalText(baseBranch) ?? settings.defaultBaseBranch;
const jobRuntime = requestedRuntime ?? 'opencode';
const supported = runtimesForProfile(profile);
// An explicit request is authoritative and must be supported. A persisted
// spoon runtime (seeded to 'opencode' for every Spoon) is only honored when
// the profile can actually drive it; otherwise fall back to the profile's
// primary runtime so ChatGPT-login profiles still resolve to codex.
const settingsRuntime = settings.runtime as AgentRuntimeName | undefined;
const primaryRuntime = supported[0] ?? 'opencode';
const resolvedRuntime =
requestedRuntime ??
(settingsRuntime && supported.includes(settingsRuntime)
? settingsRuntime
: primaryRuntime);
if (!supported.includes(resolvedRuntime)) {
throw new ConvexError(
`Provider "${profile.name}" cannot run the "${resolvedRuntime}" runtime. Supported: ${supported.join(', ')}.`,
);
}
const shouldMaterializeEnvFile =
materializeEnvFile ?? settings.materializeEnvFileByDefault;
const envFilePath =
@@ -488,7 +509,7 @@ const insertJob = async (
jobType: requestedJobType,
status: 'queued',
prompt,
runtime: jobRuntime,
runtime: resolvedRuntime,
workspaceStatus: 'not_started',
baseBranch: resolvedBaseBranch,
workBranch,
@@ -596,6 +617,7 @@ export const createForThread = mutation({
jobType,
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')),
@@ -649,6 +671,7 @@ export const createForThread = mutation({
requestedJobType: args.jobType,
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
@@ -664,6 +687,7 @@ export const createForThreadInternal = internalMutation({
jobType,
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')),
@@ -719,6 +743,7 @@ export const createForThreadInternal = internalMutation({
requestedJobType: args.jobType,
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
@@ -1140,7 +1165,7 @@ export const claimNextInternal = internalMutation({
await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.');
return null;
}
if ((job.runtime ?? 'opencode') !== 'opencode') {
if (job.runtime === 'openai_direct') {
await failJobClaim(
ctx,
job,
@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getRequiredUserId, optionalText } from './model';
import { runtimesForProfile } from './runtimeSupport';
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
isDefault?: boolean;
@@ -59,6 +60,7 @@ const publicProfile = (
reasoningEffort: profile.reasoningEffort,
enabled: profile.enabled,
configured: isConfigured(profile),
supportedRuntimes: runtimesForProfile(profile),
isDefault: profile._id === defaultProfileId,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
+4
View File
@@ -245,6 +245,9 @@ export const createUserThread = mutation({
prompt: v.string(),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
runtime: v.optional(
v.union(v.literal('codex'), v.literal('opencode'), v.literal('claude')),
),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -284,6 +287,7 @@ export const createUserThread = mutation({
jobType: 'user_change',
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
runtime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
envFilePath: args.envFilePath,
aiProviderProfileId: args.aiProviderProfileId,
@@ -0,0 +1,133 @@
import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest';
import type { Id } from '../../convex/_generated/dataModel.js';
import { api } from '../../convex/_generated/api.js';
import schema from '../../convex/schema';
const modules = import.meta.glob('../../convex/**/*.*s');
const createUser = async (t: ReturnType<typeof convexTest>, email: string) =>
(await t.mutation(async (ctx) => {
return await ctx.db.insert('users', { email, name: email });
})) as Id<'users'>;
const authed = (t: ReturnType<typeof convexTest>, userId: string) =>
t.withIdentity({
subject: `${userId}|session`,
issuer: 'https://convex.test',
});
const githubSpoonInput = {
name: 'Editor Spoon',
provider: 'github' as const,
upstreamOwner: 'upstream',
upstreamRepo: 'editor',
upstreamDefaultBranch: 'main',
upstreamUrl: 'https://github.com/upstream/editor',
forkOwner: 'team',
forkRepo: 'editor-spoon',
forkUrl: 'https://github.com/team/editor-spoon',
visibility: 'private' as const,
maintenanceMode: 'watch' as const,
syncCadence: 'daily' as const,
productionRefStrategy: 'default_branch' as const,
};
const setup = async (
t: ReturnType<typeof convexTest>,
profile: {
provider: 'anthropic' | 'opencode_openai_login';
authType: 'api_key' | 'opencode_auth_json';
},
) => {
const ownerId = await createUser(t, 'owner@example.com');
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
githubSpoonInput,
);
const threadId = await t.mutation(async (ctx) => {
const now = Date.now();
await ctx.db.insert('aiProviderProfiles', {
ownerId,
name: 'Primary provider',
provider: profile.provider,
authType: profile.authType,
encryptedSecret: 'encrypted-secret',
secretPreview: 'sk-...abcd',
defaultModel: 'claude-sonnet-4',
modelOptions: ['claude-sonnet-4'],
reasoningEffort: 'medium',
enabled: true,
createdAt: now,
updatedAt: now,
});
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Runtime thread',
summary: 'do the work',
source: 'user_request',
status: 'open',
priority: 'normal',
createdAt: now,
updatedAt: now,
});
});
return { ownerId, spoonId, threadId };
};
describe('queue-time runtime validation', () => {
test('rejects a runtime the provider profile cannot drive', async () => {
const t = convexTest(schema, modules);
const { ownerId, threadId } = await setup(t, {
provider: 'anthropic',
authType: 'api_key',
});
await expect(
authed(t, ownerId).mutation(api.agentJobs.createForThread, {
threadId,
jobType: 'user_change',
runtime: 'codex',
}),
).rejects.toThrow(/cannot run the "codex" runtime/);
});
test('resolves a supported requested runtime onto the job', async () => {
const t = convexTest(schema, modules);
const { ownerId, threadId } = await setup(t, {
provider: 'anthropic',
authType: 'api_key',
});
const jobId = await authed(t, ownerId).mutation(
api.agentJobs.createForThread,
{
threadId,
jobType: 'user_change',
runtime: 'claude',
},
);
const job = await t.run(async (ctx) => await ctx.db.get(jobId));
expect(job?.runtime).toBe('claude');
});
test('defaults a ChatGPT-login profile job to the codex runtime', async () => {
const t = convexTest(schema, modules);
const { ownerId, threadId } = await setup(t, {
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
});
const jobId = await authed(t, ownerId).mutation(
api.agentJobs.createForThread,
{
threadId,
jobType: 'user_change',
},
);
const job = await t.run(async (ctx) => await ctx.db.get(jobId));
expect(job?.runtime).toBe('codex');
});
});