Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
+772 -52
View File
@@ -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(),