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,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,13 @@ const spoonInput = {
|
||||
productionRefStrategy: 'default_branch' as const,
|
||||
};
|
||||
|
||||
const githubSpoonInput = {
|
||||
...spoonInput,
|
||||
provider: 'github' as const,
|
||||
upstreamUrl: 'https://github.com/upstream/editor',
|
||||
forkUrl: 'https://github.com/team/editor-spoon',
|
||||
};
|
||||
|
||||
const createAgentJob = async (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
args: {
|
||||
@@ -114,6 +121,54 @@ describe('convex-test harness', () => {
|
||||
expect(spoons[0]?.ownerId).toBe(userId);
|
||||
});
|
||||
|
||||
test('lists effective drift after ignored upstream changes', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
githubSpoonInput,
|
||||
);
|
||||
await t.mutation(async (ctx) => {
|
||||
const now = Date.now();
|
||||
await ctx.db.insert('spoonRepositoryStates', {
|
||||
spoonId,
|
||||
ownerId: ownerId as Id<'users'>,
|
||||
upstreamFullName: 'upstream/editor',
|
||||
forkFullName: 'team/editor-spoon',
|
||||
upstreamDefaultBranch: 'main',
|
||||
forkDefaultBranch: 'main',
|
||||
upstreamHeadSha: 'upstream-head',
|
||||
forkHeadSha: 'fork-head',
|
||||
upstreamAheadBy: 2,
|
||||
forkAheadBy: 1,
|
||||
status: 'diverged',
|
||||
openForkPullRequestCount: 0,
|
||||
openUpstreamPullRequestCount: 0,
|
||||
refreshedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('ignoredUpstreamChanges', {
|
||||
spoonId,
|
||||
ownerId: ownerId as Id<'users'>,
|
||||
upstreamTo: 'upstream-head',
|
||||
commitShas: ['abc123'],
|
||||
reason: 'irrelevant',
|
||||
decidedBy: 'user',
|
||||
createdAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
const spoons = await authed(t, ownerId).query(
|
||||
api.spoons.listMineWithState,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(spoons[0]?.rawUpstreamAheadBy).toBe(2);
|
||||
expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1);
|
||||
expect(spoons[0]?.ignoredUpstreamCount).toBe(1);
|
||||
});
|
||||
|
||||
test('does not allow reading another user’s Spoon', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
@@ -128,6 +183,38 @@ describe('convex-test harness', () => {
|
||||
).rejects.toThrow('Spoon not found.');
|
||||
});
|
||||
|
||||
test('thread notes are completed when no workspace handles them', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
githubSpoonInput,
|
||||
);
|
||||
const threadId = await t.mutation(async (ctx) => {
|
||||
return await ctx.db.insert('threads', {
|
||||
ownerId,
|
||||
spoonId,
|
||||
title: 'Manual note',
|
||||
source: 'user_request',
|
||||
status: 'open',
|
||||
priority: 'normal',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const messageId = await authed(t, ownerId).mutation(
|
||||
api.threads.appendUserMessage,
|
||||
{
|
||||
threadId,
|
||||
content: 'extra context',
|
||||
},
|
||||
);
|
||||
const message = await t.run(async (ctx) => await ctx.db.get(messageId));
|
||||
|
||||
expect(message?.status).toBe('completed');
|
||||
});
|
||||
|
||||
test('requires Spoon ownership for agent requests', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
@@ -211,4 +298,59 @@ describe('convex-test harness', () => {
|
||||
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
|
||||
).rejects.toThrow('Agent job not found.');
|
||||
});
|
||||
|
||||
test('queues a new thread job after the previous job is terminal', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
githubSpoonInput,
|
||||
);
|
||||
await t.mutation(async (ctx) => {
|
||||
await ctx.db.insert('aiProviderProfiles', {
|
||||
ownerId,
|
||||
name: 'Test provider',
|
||||
provider: 'openai',
|
||||
authType: 'none',
|
||||
defaultModel: 'gpt-5.1-codex',
|
||||
modelOptions: ['gpt-5.1-codex'],
|
||||
reasoningEffort: 'medium',
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
const threadId = await t.mutation(async (ctx) => {
|
||||
return await ctx.db.insert('threads', {
|
||||
ownerId,
|
||||
spoonId,
|
||||
title: 'Retryable thread',
|
||||
summary: 'try again',
|
||||
source: 'user_request',
|
||||
status: 'failed',
|
||||
priority: 'normal',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
const failedJobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'failed',
|
||||
workspaceStatus: 'failed',
|
||||
});
|
||||
await t.mutation(async (ctx) => {
|
||||
await ctx.db.patch(threadId, { latestAgentJobId: failedJobId });
|
||||
});
|
||||
|
||||
const newJobId = await authed(t, ownerId).mutation(
|
||||
api.agentJobs.createForThread,
|
||||
{
|
||||
threadId,
|
||||
jobType: 'user_change',
|
||||
},
|
||||
);
|
||||
|
||||
expect(newJobId).not.toBe(failedJobId);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user