Move to threads based system.
This commit is contained in:
@@ -18,6 +18,51 @@ const jobStatus = v.union(
|
||||
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 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'),
|
||||
@@ -55,13 +100,34 @@ const artifactContentType = v.union(
|
||||
v.literal('text/x-diff'),
|
||||
);
|
||||
|
||||
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: 'gpt-5.1-codex',
|
||||
reasoningEffort: 'high' as const,
|
||||
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();
|
||||
@@ -94,6 +160,18 @@ const buildBranch = (
|
||||
)}`;
|
||||
};
|
||||
|
||||
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 getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
||||
const settings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
@@ -120,12 +198,207 @@ const assertSecretOwnership = async (
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -137,74 +410,158 @@ export const createFromRequest = mutation({
|
||||
throw new ConvexError('This request already has an agent job.');
|
||||
}
|
||||
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
const settings = await getAgentSettings(ctx, spoon);
|
||||
if (!settings.enabled) {
|
||||
throw new ConvexError('Agent jobs are disabled for this Spoon.');
|
||||
}
|
||||
await assertSecretOwnership(
|
||||
ctx,
|
||||
spoon._id,
|
||||
ownerId,
|
||||
args.selectedSecretIds,
|
||||
);
|
||||
const now = Date.now();
|
||||
const baseBranch =
|
||||
optionalText(args.baseBranch) ?? settings.defaultBaseBranch;
|
||||
const workBranch = buildBranch(
|
||||
request._id,
|
||||
request.prompt,
|
||||
settings.branchPrefix,
|
||||
args.requestedBranchName,
|
||||
);
|
||||
const jobId = await ctx.db.insert('agentJobs', {
|
||||
spoonId: spoon._id,
|
||||
return await insertJob(ctx, {
|
||||
ownerId,
|
||||
agentRequestId: request._id,
|
||||
status: 'queued',
|
||||
spoon,
|
||||
requestId: request._id,
|
||||
prompt: request.prompt,
|
||||
baseBranch,
|
||||
workBranch,
|
||||
githubInstallationId: spoon.githubInstallationId,
|
||||
forkOwner: spoon.forkOwner,
|
||||
forkRepo: spoon.forkRepo,
|
||||
forkUrl: spoon.forkUrl,
|
||||
upstreamOwner: spoon.upstreamOwner,
|
||||
upstreamRepo: spoon.upstreamRepo,
|
||||
selectedSecretIds: args.selectedSecretIds,
|
||||
model: settings.agentModel,
|
||||
reasoningEffort: settings.reasoningEffort,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
settings,
|
||||
requestedJobType: 'user_change',
|
||||
baseBranch: args.baseBranch,
|
||||
requestedBranchName: args.requestedBranchName,
|
||||
requestedRuntime: args.runtime,
|
||||
materializeEnvFile: args.materializeEnvFile,
|
||||
requestedEnvFilePath: args.envFilePath,
|
||||
requestedProfileId: args.aiProviderProfileId,
|
||||
});
|
||||
await ctx.db.patch(request._id, {
|
||||
agentJobId: jobId,
|
||||
selectedSecretIds: args.selectedSecretIds,
|
||||
baseBranch,
|
||||
requestedBranchName: optionalText(args.requestedBranchName),
|
||||
status: 'queued',
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('agentJobEvents', {
|
||||
jobId,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
level: 'info',
|
||||
phase: 'queued',
|
||||
message: 'Agent job queued.',
|
||||
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 }) => {
|
||||
@@ -228,6 +585,66 @@ export const get = query({
|
||||
},
|
||||
});
|
||||
|
||||
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 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 }) => {
|
||||
@@ -312,6 +729,9 @@ export const claimNextInternal = internalMutation({
|
||||
.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);
|
||||
@@ -343,6 +763,8 @@ export const claimNextInternal = internalMutation({
|
||||
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
|
||||
spoon,
|
||||
aiSettings,
|
||||
aiProviderProfile:
|
||||
aiProviderProfile?.ownerId === job.ownerId ? aiProviderProfile : null,
|
||||
agentSettings,
|
||||
secrets,
|
||||
};
|
||||
@@ -380,6 +802,110 @@ export const updateStatus = mutation({
|
||||
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 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 };
|
||||
},
|
||||
});
|
||||
@@ -416,6 +942,98 @@ export const completeWithDraftPr = mutation({
|
||||
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 };
|
||||
},
|
||||
});
|
||||
@@ -449,6 +1067,108 @@ export const appendEvent = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user