feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker
This commit is contained in:
@@ -38,7 +38,7 @@ export type Claim = {
|
|||||||
| 'cloudflare_ai_gateway'
|
| 'cloudflare_ai_gateway'
|
||||||
| 'custom_openai_compatible'
|
| 'custom_openai_compatible'
|
||||||
| 'opencode_openai_login';
|
| 'opencode_openai_login';
|
||||||
authType: 'api_key' | 'opencode_auth_json' | 'none';
|
authType: 'api_key' | 'opencode_auth_json' | 'anthropic_oauth_json' | 'none';
|
||||||
secret?: string;
|
secret?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -172,9 +172,9 @@ export const codexModelArgs = (claim: Claim) =>
|
|||||||
|
|
||||||
// Claude Code OAuth profiles store their credentials as a JSON blob written to
|
// Claude Code OAuth profiles store their credentials as a JSON blob written to
|
||||||
// `~/.claude/.credentials.json`; API-key profiles authenticate via
|
// `~/.claude/.credentials.json`; API-key profiles authenticate via
|
||||||
// ANTHROPIC_API_KEY. Mirrors the OpenCode adapter's `opencode_auth_json` signal.
|
// ANTHROPIC_API_KEY. Keyed on the Anthropic OAuth snapshot type.
|
||||||
export const isClaudeOAuthProfile = (claim: Claim) =>
|
export const isClaudeOAuthProfile = (claim: Claim) =>
|
||||||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
|
claim.aiProviderProfile?.authType === 'anthropic_oauth_json';
|
||||||
|
|
||||||
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
|
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
|
||||||
// so strip any `provider/` prefix like codexModel does.
|
// so strip any `provider/` prefix like codexModel does.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
|||||||
import type { NormalizedAgentEvent } from './agent-events';
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
|
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
||||||
import { getAdapter } from './runtime/agent-runtime';
|
import { getAdapter } from './runtime/agent-runtime';
|
||||||
import { resolveTurnOutcome } from './runtime/turn-outcome';
|
import { resolveTurnOutcome } from './runtime/turn-outcome';
|
||||||
import './runtime/register';
|
import './runtime/register';
|
||||||
@@ -35,10 +36,7 @@ import {
|
|||||||
listWorkspaceContainerNames,
|
listWorkspaceContainerNames,
|
||||||
runExecInContainer,
|
runExecInContainer,
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
import {
|
import { collectJsonStringValues } from './runtime/provider';
|
||||||
collectJsonStringValues,
|
|
||||||
isCodexLoginProfile,
|
|
||||||
} from './runtime/provider';
|
|
||||||
import { acquireUserBox, runningBoxUsernames } from './user-container';
|
import { acquireUserBox, runningBoxUsernames } from './user-container';
|
||||||
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
||||||
import { teardownWorkspace } from './workspace-teardown';
|
import { teardownWorkspace } from './workspace-teardown';
|
||||||
@@ -876,10 +874,9 @@ const runClaim = async (claim: Claim) => {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const redact = createRedactor(secretValues);
|
const redact = createRedactor(secretValues);
|
||||||
let acquiredBoxHandle: BoxHandle | undefined;
|
let acquiredBoxHandle: BoxHandle | undefined;
|
||||||
|
// job.runtime is authoritative (validated against the profile at queue time).
|
||||||
|
const runtime = (claim.job.runtime ?? 'opencode') as AgentRuntimeName;
|
||||||
try {
|
try {
|
||||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
|
||||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
|
||||||
}
|
|
||||||
await updateStatus(jobId, 'preparing');
|
await updateStatus(jobId, 'preparing');
|
||||||
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
|
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
|
||||||
if (!claim.github.installationId) {
|
if (!claim.github.installationId) {
|
||||||
@@ -937,9 +934,7 @@ const runClaim = async (claim: Claim) => {
|
|||||||
boxHandle,
|
boxHandle,
|
||||||
githubToken,
|
githubToken,
|
||||||
redact,
|
redact,
|
||||||
// job.runtime becomes authoritative in Task 8; until then resolve from
|
runtime,
|
||||||
// the profile so dispatch stays byte-identical to today.
|
|
||||||
runtime: isCodexLoginProfile(claim) ? 'codex' : 'opencode',
|
|
||||||
};
|
};
|
||||||
if (userEnv) {
|
if (userEnv) {
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
@@ -956,15 +951,7 @@ const runClaim = async (claim: Claim) => {
|
|||||||
redact,
|
redact,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isCodexLoginProfile(claim)) {
|
await getAdapter(runtime).prepareAuth(workspace);
|
||||||
await getAdapter(workspace.runtime).prepareAuth(workspace);
|
|
||||||
await appendEvent(
|
|
||||||
jobId,
|
|
||||||
'info',
|
|
||||||
'clone',
|
|
||||||
'Prepared Codex auth JSON for the isolated workspace.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await materializeEnvFile(workspace);
|
await materializeEnvFile(workspace);
|
||||||
const detected = await detectPackageCommands(repoDir);
|
const detected = await detectPackageCommands(repoDir);
|
||||||
await addArtifact({
|
await addArtifact({
|
||||||
@@ -1617,9 +1604,9 @@ export const startWorker = async () => {
|
|||||||
await sleep(env.pollMs);
|
await sleep(env.pollMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The Convex claim still carries the legacy `openai_direct` runtime
|
// The generated `Doc<'agentJobs'>.runtime` still includes the legacy
|
||||||
// literal, which `runClaim` rejects. Narrow to the shared `Claim` shape
|
// `openai_direct` literal (queue-time validation now excludes it), so
|
||||||
// (widened to the codex/opencode/claude union) at this boundary.
|
// narrow to the worker's codex/opencode/claude `Claim` shape here.
|
||||||
await runClaim(claim as Claim);
|
await runClaim(claim as Claim);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
||||||
@@ -51,6 +55,69 @@ const makeWorkspace = (): AdapterWorkspace => ({
|
|||||||
runtime: 'claude',
|
runtime: 'claude',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeOAuthWorkspace = (
|
||||||
|
homeDir: string,
|
||||||
|
authType: 'anthropic_oauth_json' | 'opencode_auth_json',
|
||||||
|
): AdapterWorkspace => ({
|
||||||
|
claim: {
|
||||||
|
...claim,
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'Anthropic',
|
||||||
|
provider: 'anthropic',
|
||||||
|
authType,
|
||||||
|
secret: JSON.stringify({ access_token: 'oauth-token' }),
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
} as unknown as Claim,
|
||||||
|
workdir: homeDir,
|
||||||
|
homeDir,
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: path.join(homeDir, 'Code/spoon'),
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (tempDirs.length) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (dir) await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ClaudeCodeAdapter prepareAuth', () => {
|
||||||
|
test('writes credentials.json for an anthropic_oauth_json profile', async () => {
|
||||||
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
|
||||||
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
|
const adapter = createClaudeAdapter();
|
||||||
|
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'));
|
||||||
|
|
||||||
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
|
const contents = await readFile(credentialsPath, 'utf8');
|
||||||
|
expect(contents).toContain('oauth-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not write credentials.json for an opencode_auth_json profile', async () => {
|
||||||
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
|
||||||
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
|
const adapter = createClaudeAdapter();
|
||||||
|
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json'));
|
||||||
|
|
||||||
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
|
await expect(stat(credentialsPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ClaudeCodeAdapter', () => {
|
describe('ClaudeCodeAdapter', () => {
|
||||||
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
||||||
const { createClaudeAdapter } = await loadAdapter();
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { Doc, Id } from './_generated/dataModel';
|
|||||||
import type { MutationCtx } from './_generated/server';
|
import type { MutationCtx } from './_generated/server';
|
||||||
import { internalMutation, mutation, query } from './_generated/server';
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
|
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
|
||||||
|
import type { AgentRuntimeName } from './runtimeSupport';
|
||||||
|
import { runtimesForProfile } from './runtimeSupport';
|
||||||
import { assertWorkerToken } from './workerAuth';
|
import { assertWorkerToken } from './workerAuth';
|
||||||
|
|
||||||
const jobStatus = v.union(
|
const jobStatus = v.union(
|
||||||
@@ -19,7 +21,11 @@ const jobStatus = v.union(
|
|||||||
v.literal('timed_out'),
|
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(
|
const jobType = v.union(
|
||||||
v.literal('user_change'),
|
v.literal('user_change'),
|
||||||
@@ -141,7 +147,6 @@ const maintenanceRisk = v.union(
|
|||||||
|
|
||||||
const defaultAgentSettings = {
|
const defaultAgentSettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
runtime: 'opencode' as const,
|
|
||||||
branchPrefix: 'spoon/agent',
|
branchPrefix: 'spoon/agent',
|
||||||
agentModel: '',
|
agentModel: '',
|
||||||
reasoningEffort: 'medium' as const,
|
reasoningEffort: 'medium' as const,
|
||||||
@@ -440,7 +445,7 @@ const insertJob = async (
|
|||||||
| 'conflict_resolution';
|
| 'conflict_resolution';
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
requestedBranchName?: string;
|
requestedBranchName?: string;
|
||||||
requestedRuntime?: 'opencode';
|
requestedRuntime?: AgentRuntimeName;
|
||||||
materializeEnvFile?: boolean;
|
materializeEnvFile?: boolean;
|
||||||
requestedEnvFilePath?: string;
|
requestedEnvFilePath?: string;
|
||||||
requestedProfileId?: Id<'aiProviderProfiles'>;
|
requestedProfileId?: Id<'aiProviderProfiles'>;
|
||||||
@@ -464,7 +469,23 @@ const insertJob = async (
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const resolvedBaseBranch =
|
const resolvedBaseBranch =
|
||||||
optionalText(baseBranch) ?? settings.defaultBaseBranch;
|
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 =
|
const shouldMaterializeEnvFile =
|
||||||
materializeEnvFile ?? settings.materializeEnvFileByDefault;
|
materializeEnvFile ?? settings.materializeEnvFileByDefault;
|
||||||
const envFilePath =
|
const envFilePath =
|
||||||
@@ -488,7 +509,7 @@ const insertJob = async (
|
|||||||
jobType: requestedJobType,
|
jobType: requestedJobType,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
prompt,
|
prompt,
|
||||||
runtime: jobRuntime,
|
runtime: resolvedRuntime,
|
||||||
workspaceStatus: 'not_started',
|
workspaceStatus: 'not_started',
|
||||||
baseBranch: resolvedBaseBranch,
|
baseBranch: resolvedBaseBranch,
|
||||||
workBranch,
|
workBranch,
|
||||||
@@ -596,6 +617,7 @@ export const createForThread = mutation({
|
|||||||
jobType,
|
jobType,
|
||||||
baseBranch: v.optional(v.string()),
|
baseBranch: v.optional(v.string()),
|
||||||
requestedBranchName: v.optional(v.string()),
|
requestedBranchName: v.optional(v.string()),
|
||||||
|
runtime: v.optional(runtime),
|
||||||
materializeEnvFile: v.optional(v.boolean()),
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
envFilePath: v.optional(v.string()),
|
envFilePath: v.optional(v.string()),
|
||||||
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
@@ -649,6 +671,7 @@ export const createForThread = mutation({
|
|||||||
requestedJobType: args.jobType,
|
requestedJobType: args.jobType,
|
||||||
baseBranch: args.baseBranch,
|
baseBranch: args.baseBranch,
|
||||||
requestedBranchName: args.requestedBranchName,
|
requestedBranchName: args.requestedBranchName,
|
||||||
|
requestedRuntime: args.runtime,
|
||||||
materializeEnvFile: args.materializeEnvFile,
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
requestedEnvFilePath: args.envFilePath,
|
requestedEnvFilePath: args.envFilePath,
|
||||||
requestedProfileId: args.aiProviderProfileId,
|
requestedProfileId: args.aiProviderProfileId,
|
||||||
@@ -664,6 +687,7 @@ export const createForThreadInternal = internalMutation({
|
|||||||
jobType,
|
jobType,
|
||||||
baseBranch: v.optional(v.string()),
|
baseBranch: v.optional(v.string()),
|
||||||
requestedBranchName: v.optional(v.string()),
|
requestedBranchName: v.optional(v.string()),
|
||||||
|
runtime: v.optional(runtime),
|
||||||
materializeEnvFile: v.optional(v.boolean()),
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
envFilePath: v.optional(v.string()),
|
envFilePath: v.optional(v.string()),
|
||||||
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
@@ -719,6 +743,7 @@ export const createForThreadInternal = internalMutation({
|
|||||||
requestedJobType: args.jobType,
|
requestedJobType: args.jobType,
|
||||||
baseBranch: args.baseBranch,
|
baseBranch: args.baseBranch,
|
||||||
requestedBranchName: args.requestedBranchName,
|
requestedBranchName: args.requestedBranchName,
|
||||||
|
requestedRuntime: args.runtime,
|
||||||
materializeEnvFile: args.materializeEnvFile,
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
requestedEnvFilePath: args.envFilePath,
|
requestedEnvFilePath: args.envFilePath,
|
||||||
requestedProfileId: args.aiProviderProfileId,
|
requestedProfileId: args.aiProviderProfileId,
|
||||||
@@ -1140,7 +1165,7 @@ export const claimNextInternal = internalMutation({
|
|||||||
await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.');
|
await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if ((job.runtime ?? 'opencode') !== 'opencode') {
|
if (job.runtime === 'openai_direct') {
|
||||||
await failJobClaim(
|
await failJobClaim(
|
||||||
ctx,
|
ctx,
|
||||||
job,
|
job,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
|
|||||||
import type { MutationCtx } from './_generated/server';
|
import type { MutationCtx } from './_generated/server';
|
||||||
import { internalMutation, mutation, query } from './_generated/server';
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
import { getRequiredUserId, optionalText } from './model';
|
import { getRequiredUserId, optionalText } from './model';
|
||||||
|
import { runtimesForProfile } from './runtimeSupport';
|
||||||
|
|
||||||
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
|
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
@@ -59,6 +60,7 @@ const publicProfile = (
|
|||||||
reasoningEffort: profile.reasoningEffort,
|
reasoningEffort: profile.reasoningEffort,
|
||||||
enabled: profile.enabled,
|
enabled: profile.enabled,
|
||||||
configured: isConfigured(profile),
|
configured: isConfigured(profile),
|
||||||
|
supportedRuntimes: runtimesForProfile(profile),
|
||||||
isDefault: profile._id === defaultProfileId,
|
isDefault: profile._id === defaultProfileId,
|
||||||
createdAt: profile.createdAt,
|
createdAt: profile.createdAt,
|
||||||
updatedAt: profile.updatedAt,
|
updatedAt: profile.updatedAt,
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ export const createUserThread = mutation({
|
|||||||
prompt: v.string(),
|
prompt: v.string(),
|
||||||
baseBranch: v.optional(v.string()),
|
baseBranch: v.optional(v.string()),
|
||||||
requestedBranchName: 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()),
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
envFilePath: v.optional(v.string()),
|
envFilePath: v.optional(v.string()),
|
||||||
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
@@ -284,6 +287,7 @@ export const createUserThread = mutation({
|
|||||||
jobType: 'user_change',
|
jobType: 'user_change',
|
||||||
baseBranch: args.baseBranch,
|
baseBranch: args.baseBranch,
|
||||||
requestedBranchName: args.requestedBranchName,
|
requestedBranchName: args.requestedBranchName,
|
||||||
|
runtime: args.runtime,
|
||||||
materializeEnvFile: args.materializeEnvFile,
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
envFilePath: args.envFilePath,
|
envFilePath: args.envFilePath,
|
||||||
aiProviderProfileId: args.aiProviderProfileId,
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user