1597 lines
46 KiB
TypeScript
1597 lines
46 KiB
TypeScript
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 agentRuntimeMode = v.union(
|
|
v.literal('opencode_server'),
|
|
v.literal('codex_exec'),
|
|
v.literal('legacy_cli'),
|
|
);
|
|
|
|
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 interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));
|
|
|
|
const interactionKind = v.union(
|
|
v.literal('question'),
|
|
v.literal('permission'),
|
|
v.literal('tool_confirmation'),
|
|
);
|
|
|
|
const interactionStatus = v.union(
|
|
v.literal('pending'),
|
|
v.literal('answered'),
|
|
v.literal('approved'),
|
|
v.literal('rejected'),
|
|
v.literal('expired'),
|
|
);
|
|
|
|
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 normalizeWorkspacePath = (value: string) => {
|
|
const trimmed = optionalText(value);
|
|
if (!trimmed) throw new ConvexError('Workspace path is required.');
|
|
if (
|
|
trimmed.startsWith('/') ||
|
|
trimmed.includes('\0') ||
|
|
trimmed.split('/').includes('..') ||
|
|
trimmed === '.git' ||
|
|
trimmed.startsWith('.git/')
|
|
) {
|
|
throw new ConvexError('Workspace path must stay inside the repository.');
|
|
}
|
|
return trimmed.replace(/^\.\/+/, '');
|
|
};
|
|
|
|
const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
|
|
values
|
|
?.map(normalizeWorkspacePath)
|
|
.filter((value, index, all) => all.indexOf(value) === index)
|
|
.slice(0, max);
|
|
|
|
const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
|
|
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
|
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
|
|
|
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
|
|
const messages = await ctx.db
|
|
.query('agentJobMessages')
|
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
|
.collect();
|
|
const events = await ctx.db
|
|
.query('agentJobEvents')
|
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
|
.collect();
|
|
const artifacts = await ctx.db
|
|
.query('agentJobArtifacts')
|
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
|
.collect();
|
|
const changes = await ctx.db
|
|
.query('agentWorkspaceChanges')
|
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
|
.collect();
|
|
const uiStates = await ctx.db
|
|
.query('agentWorkspaceUiStates')
|
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
|
.collect();
|
|
const interactions = await ctx.db
|
|
.query('agentInteractionRequests')
|
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
|
.collect();
|
|
|
|
for (const row of [
|
|
...messages,
|
|
...events,
|
|
...artifacts,
|
|
...changes,
|
|
...uiStates,
|
|
...interactions,
|
|
]) {
|
|
await ctx.db.delete(row._id);
|
|
}
|
|
if (job.threadId) {
|
|
const thread = await ctx.db.get(job.threadId);
|
|
if (thread?.latestAgentJobId === job._id) {
|
|
await ctx.db.patch(job.threadId, {
|
|
latestAgentJobId: undefined,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
await ctx.db.delete(job._id);
|
|
};
|
|
|
|
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<ReturnType<typeof getAgentSettings>>;
|
|
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 getWorkspaceUiState = 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.');
|
|
const state = await ctx.db
|
|
.query('agentWorkspaceUiStates')
|
|
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
|
.first();
|
|
return (
|
|
state ?? {
|
|
jobId,
|
|
spoonId: job.spoonId,
|
|
ownerId,
|
|
openFilePaths: [],
|
|
activeFilePath: undefined,
|
|
vimEnabled: false,
|
|
expandedDirectoryPaths: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
);
|
|
},
|
|
});
|
|
|
|
export const patchWorkspaceUiState = mutation({
|
|
args: {
|
|
jobId: v.id('agentJobs'),
|
|
openFilePaths: v.optional(v.array(v.string())),
|
|
activeFilePath: v.optional(v.string()),
|
|
vimEnabled: v.optional(v.boolean()),
|
|
expandedDirectoryPaths: v.optional(v.array(v.string())),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const job = await ctx.db.get(args.jobId);
|
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
|
const now = Date.now();
|
|
const existing = await ctx.db
|
|
.query('agentWorkspaceUiStates')
|
|
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
|
|
.first();
|
|
const patch = {
|
|
...(args.openFilePaths !== undefined
|
|
? { openFilePaths: normalizeWorkspacePaths(args.openFilePaths, 40) }
|
|
: {}),
|
|
...(args.activeFilePath !== undefined
|
|
? {
|
|
activeFilePath: args.activeFilePath
|
|
? normalizeWorkspacePath(args.activeFilePath)
|
|
: undefined,
|
|
}
|
|
: {}),
|
|
...(args.vimEnabled !== undefined ? { vimEnabled: args.vimEnabled } : {}),
|
|
...(args.expandedDirectoryPaths !== undefined
|
|
? {
|
|
expandedDirectoryPaths: normalizeWorkspacePaths(
|
|
args.expandedDirectoryPaths,
|
|
500,
|
|
),
|
|
}
|
|
: {}),
|
|
updatedAt: now,
|
|
};
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, patch);
|
|
return existing._id;
|
|
}
|
|
return await ctx.db.insert('agentWorkspaceUiStates', {
|
|
jobId: args.jobId,
|
|
spoonId: job.spoonId,
|
|
ownerId,
|
|
openFilePaths: patch.openFilePaths ?? [],
|
|
activeFilePath: patch.activeFilePath,
|
|
vimEnabled: patch.vimEnabled ?? false,
|
|
expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const listInteractionRequests = query({
|
|
args: {
|
|
jobId: v.id('agentJobs'),
|
|
status: v.optional(v.union(v.literal('pending'), v.literal('all'))),
|
|
},
|
|
handler: async (ctx, { jobId, status }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const job = await ctx.db.get(jobId);
|
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
|
if (status === 'pending') {
|
|
return await ctx.db
|
|
.query('agentInteractionRequests')
|
|
.withIndex('by_job_status', (q) =>
|
|
q.eq('jobId', jobId).eq('status', 'pending'),
|
|
)
|
|
.order('asc')
|
|
.collect();
|
|
}
|
|
return await ctx.db
|
|
.query('agentInteractionRequests')
|
|
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
|
.order('asc')
|
|
.collect();
|
|
},
|
|
});
|
|
|
|
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 deleteWorkspace = 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 (!isDeletableWorkspace(job)) {
|
|
throw new ConvexError(
|
|
'Only stopped, cancelled, failed, or expired workspaces can be deleted.',
|
|
);
|
|
}
|
|
await deleteWorkspaceRows(ctx, job);
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
export const countOldWorkspaces = query({
|
|
args: { olderThanDays: v.optional(v.number()) },
|
|
handler: async (ctx, { olderThanDays }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const cutoff =
|
|
olderThanDays && olderThanDays > 0
|
|
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
|
: Number.POSITIVE_INFINITY;
|
|
const jobs = await ctx.db
|
|
.query('agentJobs')
|
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
|
.collect();
|
|
return jobs.filter(
|
|
(job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff,
|
|
).length;
|
|
},
|
|
});
|
|
|
|
export const deleteOldWorkspaces = mutation({
|
|
args: {
|
|
olderThanDays: v.optional(v.number()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, { olderThanDays, limit }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const cutoff =
|
|
olderThanDays && olderThanDays > 0
|
|
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
|
: Number.POSITIVE_INFINITY;
|
|
const max = Math.min(Math.max(limit ?? 50, 1), 100);
|
|
const jobs = await ctx.db
|
|
.query('agentJobs')
|
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
|
.collect();
|
|
const deletable = jobs
|
|
.filter((job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff)
|
|
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
.slice(0, max);
|
|
for (const job of deletable) {
|
|
await deleteWorkspaceRows(ctx, job);
|
|
}
|
|
return { deleted: deletable.length };
|
|
},
|
|
});
|
|
|
|
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<Doc<'agentJobs'>> = {
|
|
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<Doc<'threads'>> = {
|
|
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 setRuntimeSession = mutation({
|
|
args: {
|
|
workerToken: v.string(),
|
|
workerId: v.string(),
|
|
jobId: v.id('agentJobs'),
|
|
agentRuntimeMode,
|
|
opencodeSessionId: v.optional(v.string()),
|
|
codexSessionId: v.optional(v.string()),
|
|
containerId: 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.');
|
|
}
|
|
await ctx.db.patch(args.jobId, {
|
|
agentRuntimeMode: args.agentRuntimeMode,
|
|
opencodeSessionId: optionalText(args.opencodeSessionId),
|
|
codexSessionId: optionalText(args.codexSessionId),
|
|
containerId: optionalText(args.containerId),
|
|
updatedAt: Date.now(),
|
|
});
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
export const setCodexSessionId = mutation({
|
|
args: {
|
|
workerToken: v.string(),
|
|
workerId: v.string(),
|
|
jobId: v.id('agentJobs'),
|
|
codexSessionId: 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.');
|
|
}
|
|
await ctx.db.patch(args.jobId, {
|
|
codexSessionId: optionalText(args.codexSessionId),
|
|
agentRuntimeMode: 'codex_exec',
|
|
updatedAt: Date.now(),
|
|
});
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
export const createInteractionRequest = mutation({
|
|
args: {
|
|
workerToken: v.string(),
|
|
workerId: v.string(),
|
|
jobId: v.id('agentJobs'),
|
|
runtime: interactionRuntime,
|
|
externalRequestId: v.string(),
|
|
kind: interactionKind,
|
|
title: v.string(),
|
|
body: v.string(),
|
|
options: v.optional(v.array(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.');
|
|
}
|
|
const now = Date.now();
|
|
const existing = (
|
|
await ctx.db
|
|
.query('agentInteractionRequests')
|
|
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
|
|
.collect()
|
|
).find((request) => request.externalRequestId === args.externalRequestId);
|
|
const record = {
|
|
runtime: args.runtime,
|
|
externalRequestId: args.externalRequestId,
|
|
kind: args.kind,
|
|
title: args.title,
|
|
body: args.body,
|
|
options: args.options,
|
|
metadata: args.metadata,
|
|
status: 'pending' as const,
|
|
updatedAt: now,
|
|
};
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, record);
|
|
return existing._id;
|
|
}
|
|
const requestId = await ctx.db.insert('agentInteractionRequests', {
|
|
jobId: args.jobId,
|
|
spoonId: job.spoonId,
|
|
ownerId: job.ownerId,
|
|
...record,
|
|
createdAt: now,
|
|
});
|
|
await ctx.db.patch(args.jobId, {
|
|
status: 'running',
|
|
updatedAt: now,
|
|
});
|
|
return requestId;
|
|
},
|
|
});
|
|
|
|
export const patchInteractionRequest = mutation({
|
|
args: {
|
|
workerToken: v.string(),
|
|
workerId: v.string(),
|
|
interactionId: v.id('agentInteractionRequests'),
|
|
status: interactionStatus,
|
|
response: v.optional(v.string()),
|
|
metadata: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
requireWorkerToken(args.workerToken);
|
|
const interaction = await ctx.db.get(args.interactionId);
|
|
if (!interaction) throw new ConvexError('Interaction request not found.');
|
|
const job = await ctx.db.get(interaction.jobId);
|
|
if (job?.claimedBy !== args.workerId) {
|
|
throw new ConvexError('Agent job not claimed by this worker.');
|
|
}
|
|
await ctx.db.patch(args.interactionId, {
|
|
status: args.status,
|
|
response: optionalText(args.response),
|
|
metadata: args.metadata,
|
|
updatedAt: Date.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<Doc<'threads'>> = {
|
|
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<Doc<'agentJobMessages'>> = {
|
|
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(),
|
|
});
|
|
},
|
|
});
|