Files
Gabriel Brown d207b8b0b8
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s
Add features & update project
2026-06-23 02:06:58 -04:00

406 lines
12 KiB
TypeScript

import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
optionalText,
requireText,
} from './model';
const provider = v.union(
v.literal('github'),
v.literal('gitea'),
v.literal('gitlab'),
v.literal('other'),
);
const visibility = v.union(
v.literal('public'),
v.literal('private'),
v.literal('internal'),
v.literal('unknown'),
);
const maintenanceMode = v.union(
v.literal('watch'),
v.literal('auto_pr'),
v.literal('paused'),
);
const syncCadence = v.union(
v.literal('daily'),
v.literal('weekly'),
v.literal('manual'),
);
const productionRefStrategy = v.union(
v.literal('default_branch'),
v.literal('latest_release'),
v.literal('tag_pattern'),
);
const spoonStatus = v.union(
v.literal('draft'),
v.literal('active'),
v.literal('needs_connection'),
v.literal('paused'),
v.literal('archived'),
);
const spoonSyncStatus = v.union(
v.literal('unknown'),
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('checking'),
v.literal('conflict'),
v.literal('error'),
);
const hasForkMetadata = (args: {
forkOwner?: string;
forkRepo?: string;
forkUrl?: string;
}) =>
Boolean(
args.forkOwner?.trim() && args.forkRepo?.trim() && args.forkUrl?.trim(),
);
export const listMine = 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();
return spoons.filter((spoon) => spoon.status !== 'archived');
},
});
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 }) => {
const ownerId = await getRequiredUserId(ctx);
return getOwnedSpoon(ctx, spoonId, ownerId);
},
});
export const getDetails = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
const [
state,
settings,
latestReview,
recentRuns,
agentRequests,
ignoredChanges,
] = await Promise.all([
ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first(),
ctx.db
.query('spoonSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.first(),
ctx.db
.query('aiReviews')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.first(),
ctx.db
.query('syncRuns')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
ctx.db
.query('agentRequests')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
ctx.db
.query('ignoredUpstreamChanges')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.collect(),
]);
const ignoredShas = new Set(
ignoredChanges.flatMap((change) => change.commitShas),
);
const effectiveUpstreamAheadBy = Math.max(
0,
(state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0) - ignoredShas.size,
);
return {
spoon,
state,
settings,
latestReview,
recentRuns,
agentRequests,
ignoredChanges,
effectiveUpstreamAheadBy,
};
},
});
export const getOwnedForAction = internalQuery({
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
handler: async (ctx, { spoonId, ownerId }) => {
return await getOwnedSpoon(ctx, spoonId, ownerId);
},
});
export const createManual = mutation({
args: {
name: v.string(),
description: v.optional(v.string()),
provider,
upstreamOwner: v.string(),
upstreamRepo: v.string(),
upstreamDefaultBranch: v.string(),
upstreamUrl: v.string(),
forkOwner: v.optional(v.string()),
forkRepo: v.optional(v.string()),
forkDefaultBranch: v.optional(v.string()),
forkUrl: v.optional(v.string()),
visibility,
maintenanceMode,
syncCadence,
productionRefStrategy,
tagPattern: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const now = Date.now();
const forkOwner = optionalText(args.forkOwner);
const forkRepo = optionalText(args.forkRepo);
const forkDefaultBranch = optionalText(args.forkDefaultBranch);
const forkUrl = optionalText(args.forkUrl);
const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl })
? 'draft'
: 'needs_connection';
const spoonId = await ctx.db.insert('spoons', {
ownerId,
name: requireText(args.name, 'Spoon name'),
description: optionalText(args.description),
provider: args.provider,
upstreamOwner: requireText(args.upstreamOwner, 'Upstream owner'),
upstreamRepo: requireText(args.upstreamRepo, 'Upstream repository'),
upstreamDefaultBranch: requireText(
args.upstreamDefaultBranch,
'Upstream default branch',
),
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
forkOwner,
forkRepo,
forkDefaultBranch,
forkUrl,
visibility: args.visibility,
maintenanceMode: args.maintenanceMode,
syncCadence: args.syncCadence,
productionRefStrategy: args.productionRefStrategy,
tagPattern: optionalText(args.tagPattern),
status,
syncStatus: 'unknown',
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonSettings', {
spoonId,
ownerId,
autoRefreshEnabled: true,
autoReviewEnabled: true,
autoSyncEnabled: false,
requireAiLowRiskForSync: true,
requireCleanCompareForSync: true,
ignoredFilePatterns: [],
importantFilePatterns: [],
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('spoonAgentSettings', {
spoonId,
ownerId,
enabled: true,
runtime: 'opencode',
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
branchPrefix: 'spoon/agent',
agentModel: '',
reasoningEffort: 'medium',
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
envFilePath: '.env.local',
materializeEnvFileByDefault: false,
autoDetectCommands: true,
allowUserFileEditing: true,
aiProviderProfileId: undefined,
createdAt: now,
updatedAt: now,
});
return spoonId;
},
});
export const patchSyncFields = internalMutation({
args: {
spoonId: v.id('spoons'),
syncStatus: v.optional(spoonSyncStatus),
upstreamAheadBy: v.optional(v.number()),
forkAheadBy: v.optional(v.number()),
lastMergeBaseCommit: v.optional(v.string()),
lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()),
lastSyncRunId: v.optional(v.id('syncRuns')),
lastAiReviewId: v.optional(v.id('aiReviews')),
lastGithubRefreshAt: v.optional(v.number()),
lastSuccessfulRefreshAt: v.optional(v.number()),
lastCheckedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
},
handler: async (ctx, args) => {
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
if (args.syncStatus !== undefined) patch.syncStatus = args.syncStatus;
if (args.upstreamAheadBy !== undefined) {
patch.upstreamAheadBy = args.upstreamAheadBy;
}
if (args.forkAheadBy !== undefined) patch.forkAheadBy = args.forkAheadBy;
if (args.lastMergeBaseCommit !== undefined) {
patch.lastMergeBaseCommit = args.lastMergeBaseCommit;
}
if (args.lastUpstreamCommit !== undefined) {
patch.lastUpstreamCommit = args.lastUpstreamCommit;
}
if (args.lastForkCommit !== undefined) {
patch.lastForkCommit = args.lastForkCommit;
}
if (args.lastSyncRunId !== undefined)
patch.lastSyncRunId = args.lastSyncRunId;
if (args.lastAiReviewId !== undefined)
patch.lastAiReviewId = args.lastAiReviewId;
if (args.lastGithubRefreshAt !== undefined) {
patch.lastGithubRefreshAt = args.lastGithubRefreshAt;
}
if (args.lastSuccessfulRefreshAt !== undefined) {
patch.lastSuccessfulRefreshAt = args.lastSuccessfulRefreshAt;
}
if (args.lastCheckedAt !== undefined)
patch.lastCheckedAt = args.lastCheckedAt;
if (args.lastError !== undefined) patch.lastError = args.lastError;
await ctx.db.patch(args.spoonId, patch);
return { success: true };
},
});
export const updateSettings = mutation({
args: {
spoonId: v.id('spoons'),
maintenanceMode: v.optional(maintenanceMode),
syncCadence: v.optional(syncCadence),
productionRefStrategy: v.optional(productionRefStrategy),
tagPattern: v.optional(v.string()),
status: v.optional(spoonStatus),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
if (args.maintenanceMode) patch.maintenanceMode = args.maintenanceMode;
if (args.syncCadence) patch.syncCadence = args.syncCadence;
if (args.productionRefStrategy) {
patch.productionRefStrategy = args.productionRefStrategy;
}
if (args.tagPattern !== undefined)
patch.tagPattern = optionalText(args.tagPattern);
if (args.status) patch.status = args.status;
await ctx.db.patch(args.spoonId, patch);
return { success: true };
},
});
export const archive = mutation({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
if (spoon.status === 'archived')
throw new ConvexError('Spoon is archived.');
await ctx.db.patch(spoonId, {
status: 'archived',
updatedAt: Date.now(),
});
return { success: true };
},
});