Add features & update project
This commit is contained in:
@@ -219,6 +219,11 @@ const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
|
||||
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const isTerminalJob = (job: Doc<'agentJobs'>) =>
|
||||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||
job.status,
|
||||
) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
|
||||
const messages = await ctx.db
|
||||
.query('agentJobMessages')
|
||||
@@ -546,7 +551,10 @@ export const createForThread = mutation({
|
||||
throw new ConvexError('Thread not found.');
|
||||
}
|
||||
if (thread.latestAgentJobId) {
|
||||
throw new ConvexError('This thread already has an agent job.');
|
||||
const latestJob = await ctx.db.get(thread.latestAgentJobId);
|
||||
if (latestJob && !isTerminalJob(latestJob)) {
|
||||
throw new ConvexError('This thread already has an active agent job.');
|
||||
}
|
||||
}
|
||||
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
|
||||
const promptMessage = await ctx.db
|
||||
@@ -609,7 +617,12 @@ export const createForThreadInternal = internalMutation({
|
||||
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
||||
throw new ConvexError('Thread not found.');
|
||||
}
|
||||
if (thread.latestAgentJobId) return thread.latestAgentJobId;
|
||||
if (thread.latestAgentJobId) {
|
||||
const latestJob = await ctx.db.get(thread.latestAgentJobId);
|
||||
if (latestJob && !isTerminalJob(latestJob)) {
|
||||
return thread.latestAgentJobId;
|
||||
}
|
||||
}
|
||||
const spoon = await ctx.db.get(thread.spoonId);
|
||||
if (spoon?.ownerId !== args.ownerId) {
|
||||
throw new ConvexError('Spoon not found.');
|
||||
@@ -929,6 +942,30 @@ export const deleteWorkspace = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const markWorkspaceLost = 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.');
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(jobId, {
|
||||
status: 'failed',
|
||||
workspaceStatus: 'failed',
|
||||
error: 'Workspace is not active on the configured worker.',
|
||||
completedAt: job.completedAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (job.threadId) {
|
||||
await ctx.db.patch(job.threadId, {
|
||||
status: 'failed',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const countOldWorkspaces = query({
|
||||
args: { olderThanDays: v.optional(v.number()) },
|
||||
handler: async (ctx, { olderThanDays }) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import { query } from './_generated/server';
|
||||
import { getRequiredUserId } from './model';
|
||||
|
||||
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
const labelForModel = (model: string): string => {
|
||||
const parts = model.split('/');
|
||||
const raw = parts[parts.length - 1] ?? model;
|
||||
return raw
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\b\w/g, (letter: string) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const recommendedFor = (model: string) => {
|
||||
const lower = model.toLowerCase();
|
||||
const tags: ('coding' | 'review' | 'fast' | 'large_context')[] = [];
|
||||
if (
|
||||
lower.includes('codex') ||
|
||||
lower.includes('claude') ||
|
||||
lower.includes('sonnet')
|
||||
) {
|
||||
tags.push('coding');
|
||||
}
|
||||
if (
|
||||
lower.includes('mini') ||
|
||||
lower.includes('haiku') ||
|
||||
lower.includes('flash')
|
||||
) {
|
||||
tags.push('fast');
|
||||
}
|
||||
if (
|
||||
lower.includes('200k') ||
|
||||
lower.includes('1m') ||
|
||||
lower.includes('large')
|
||||
) {
|
||||
tags.push('large_context');
|
||||
}
|
||||
if (!tags.length) tags.push('review');
|
||||
return tags;
|
||||
};
|
||||
|
||||
export const listAvailableForUser = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const profiles = await ctx.db
|
||||
.query('aiProviderProfiles')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.collect();
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) =>
|
||||
profile.enabled &&
|
||||
(profile.authType === 'none' || Boolean(profile.encryptedSecret)),
|
||||
);
|
||||
const explicitDefault = configuredProfiles.find(
|
||||
(profile) => (profile as AiProviderProfileWithDefault).isDefault,
|
||||
);
|
||||
const defaultProfileId =
|
||||
explicitDefault?._id ??
|
||||
(configuredProfiles.length === 1
|
||||
? configuredProfiles[0]?._id
|
||||
: undefined);
|
||||
|
||||
return {
|
||||
profiles: profiles
|
||||
.filter((profile) => profile.enabled)
|
||||
.map((profile) => {
|
||||
const configured =
|
||||
profile.authType === 'none' || Boolean(profile.encryptedSecret);
|
||||
const modelIds = [
|
||||
profile.defaultModel,
|
||||
...(profile.modelOptions ?? []),
|
||||
]
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
.filter((model, index, all) => all.indexOf(model) === index);
|
||||
return {
|
||||
profileId: profile._id,
|
||||
profileName: profile.name,
|
||||
provider: profile.provider,
|
||||
configured,
|
||||
enabled: profile.enabled,
|
||||
isDefault: profile._id === defaultProfileId,
|
||||
defaultModel: profile.defaultModel,
|
||||
reasoningEffort: profile.reasoningEffort,
|
||||
models: modelIds.map((id) => ({
|
||||
id,
|
||||
label: labelForModel(id),
|
||||
recommendedFor: recommendedFor(id),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -87,6 +87,64 @@ export const listMine = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listMineWithState = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoons = (
|
||||
await ctx.db
|
||||
.query('spoons')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.collect()
|
||||
).filter((spoon) => spoon.status !== 'archived');
|
||||
|
||||
return await Promise.all(
|
||||
spoons.map(async (spoon) => {
|
||||
const [state, ignoredChanges, threads] = await Promise.all([
|
||||
ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.first(),
|
||||
ctx.db
|
||||
.query('ignoredUpstreamChanges')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query('threads')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.order('desc')
|
||||
.collect(),
|
||||
]);
|
||||
const ignoredShas = new Set(
|
||||
ignoredChanges.flatMap((change) => change.commitShas),
|
||||
);
|
||||
const rawUpstreamAheadBy =
|
||||
state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0;
|
||||
const effectiveUpstreamAheadBy = Math.max(
|
||||
0,
|
||||
rawUpstreamAheadBy - ignoredShas.size,
|
||||
);
|
||||
const openThreads = threads.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...spoon,
|
||||
rawUpstreamAheadBy,
|
||||
effectiveUpstreamAheadBy,
|
||||
ignoredUpstreamCount: ignoredShas.size,
|
||||
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
|
||||
openThreadCount: openThreads.length,
|
||||
latestThreadStatus: threads[0]?.status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const listMine = query({
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.take(args.limit ?? 50);
|
||||
return threads.filter((thread) => {
|
||||
const filtered = threads.filter((thread) => {
|
||||
if (
|
||||
args.status &&
|
||||
args.status !== 'all' &&
|
||||
@@ -100,6 +100,28 @@ export const listMine = query({
|
||||
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
||||
return true;
|
||||
});
|
||||
return await Promise.all(
|
||||
filtered.map(async (thread) => {
|
||||
const [spoon, latestJob] = await Promise.all([
|
||||
thread.spoonId ? ctx.db.get(thread.spoonId) : null,
|
||||
thread.latestAgentJobId ? ctx.db.get(thread.latestAgentJobId) : null,
|
||||
]);
|
||||
return {
|
||||
...publicThread(thread),
|
||||
spoonName: spoon?.ownerId === ownerId ? spoon.name : undefined,
|
||||
latestJobStatus:
|
||||
latestJob?.ownerId === ownerId ? latestJob.status : undefined,
|
||||
latestJobWorkspaceStatus:
|
||||
latestJob?.ownerId === ownerId
|
||||
? latestJob.workspaceStatus
|
||||
: undefined,
|
||||
latestJobPullRequestUrl:
|
||||
latestJob?.ownerId === ownerId
|
||||
? latestJob.pullRequestUrl
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,7 +238,7 @@ export const appendUserMessage = mutation({
|
||||
spoonId: thread.spoonId,
|
||||
role: 'user',
|
||||
content: requireText(content, 'Message'),
|
||||
status: 'queued',
|
||||
status: 'completed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user