Add features & update project
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s

This commit is contained in:
Gabriel Brown
2026-06-23 02:06:58 -04:00
parent fe72fc2957
commit d207b8b0b8
26 changed files with 1257 additions and 231 deletions
+39 -2
View File
@@ -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),
})),
};
}),
};
},
});
+58
View File
@@ -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 }) => {
+24 -2
View File
@@ -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,
});
+142
View File
@@ -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 users 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);
});
});