Add features & update project

This commit is contained in:
Gabriel Brown
2026-06-23 01:46:08 -04:00
parent 930fbf5965
commit fe72fc2957
39 changed files with 3106 additions and 178 deletions
+397
View File
@@ -36,6 +36,12 @@ const workspaceStatus = v.union(
v.literal('failed'),
);
const agentRuntimeMode = v.union(
v.literal('opencode_server'),
v.literal('codex_exec'),
v.literal('legacy_cli'),
);
const messageRole = v.union(
v.literal('user'),
v.literal('assistant'),
@@ -100,6 +106,22 @@ const artifactContentType = v.union(
v.literal('text/x-diff'),
);
const interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));
const interactionKind = v.union(
v.literal('question'),
v.literal('permission'),
v.literal('tool_confirmation'),
);
const interactionStatus = v.union(
v.literal('pending'),
v.literal('answered'),
v.literal('approved'),
v.literal('rejected'),
v.literal('expired'),
);
const maintenanceDecision = v.union(
v.literal('sync'),
v.literal('ignore'),
@@ -172,6 +194,79 @@ const normalizeEnvFilePath = (value?: string) => {
return trimmed;
};
const normalizeWorkspacePath = (value: string) => {
const trimmed = optionalText(value);
if (!trimmed) throw new ConvexError('Workspace path is required.');
if (
trimmed.startsWith('/') ||
trimmed.includes('\0') ||
trimmed.split('/').includes('..') ||
trimmed === '.git' ||
trimmed.startsWith('.git/')
) {
throw new ConvexError('Workspace path must stay inside the repository.');
}
return trimmed.replace(/^\.\/+/, '');
};
const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
values
?.map(normalizeWorkspacePath)
.filter((value, index, all) => all.indexOf(value) === index)
.slice(0, max);
const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
const messages = await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const events = await ctx.db
.query('agentJobEvents')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const artifacts = await ctx.db
.query('agentJobArtifacts')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const changes = await ctx.db
.query('agentWorkspaceChanges')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const uiStates = await ctx.db
.query('agentWorkspaceUiStates')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const interactions = await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
for (const row of [
...messages,
...events,
...artifacts,
...changes,
...uiStates,
...interactions,
]) {
await ctx.db.delete(row._id);
}
if (job.threadId) {
const thread = await ctx.db.get(job.threadId);
if (thread?.latestAgentJobId === job._id) {
await ctx.db.patch(job.threadId, {
latestAgentJobId: undefined,
updatedAt: Date.now(),
});
}
}
await ctx.db.delete(job._id);
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db
.query('spoonAgentSettings')
@@ -609,6 +704,115 @@ export const listMessages = query({
},
});
export const getWorkspaceUiState = 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.');
const state = await ctx.db
.query('agentWorkspaceUiStates')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.first();
return (
state ?? {
jobId,
spoonId: job.spoonId,
ownerId,
openFilePaths: [],
activeFilePath: undefined,
vimEnabled: false,
expandedDirectoryPaths: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
);
},
});
export const patchWorkspaceUiState = mutation({
args: {
jobId: v.id('agentJobs'),
openFilePaths: v.optional(v.array(v.string())),
activeFilePath: v.optional(v.string()),
vimEnabled: v.optional(v.boolean()),
expandedDirectoryPaths: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(args.jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
const now = Date.now();
const existing = await ctx.db
.query('agentWorkspaceUiStates')
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
.first();
const patch = {
...(args.openFilePaths !== undefined
? { openFilePaths: normalizeWorkspacePaths(args.openFilePaths, 40) }
: {}),
...(args.activeFilePath !== undefined
? {
activeFilePath: args.activeFilePath
? normalizeWorkspacePath(args.activeFilePath)
: undefined,
}
: {}),
...(args.vimEnabled !== undefined ? { vimEnabled: args.vimEnabled } : {}),
...(args.expandedDirectoryPaths !== undefined
? {
expandedDirectoryPaths: normalizeWorkspacePaths(
args.expandedDirectoryPaths,
500,
),
}
: {}),
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return existing._id;
}
return await ctx.db.insert('agentWorkspaceUiStates', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId,
openFilePaths: patch.openFilePaths ?? [],
activeFilePath: patch.activeFilePath,
vimEnabled: patch.vimEnabled ?? false,
expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [],
createdAt: now,
updatedAt: now,
});
},
});
export const listInteractionRequests = query({
args: {
jobId: v.id('agentJobs'),
status: v.optional(v.union(v.literal('pending'), v.literal('all'))),
},
handler: async (ctx, { jobId, status }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
if (status === 'pending') {
return await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job_status', (q) =>
q.eq('jobId', jobId).eq('status', 'pending'),
)
.order('asc')
.collect();
}
return await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('asc')
.collect();
},
});
export const appendUserMessage = mutation({
args: { jobId: v.id('agentJobs'), content: v.string() },
handler: async (ctx, { jobId, content }) => {
@@ -709,6 +913,67 @@ export const cancel = mutation({
},
});
export const deleteWorkspace = 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.');
if (!isDeletableWorkspace(job)) {
throw new ConvexError(
'Only stopped, cancelled, failed, or expired workspaces can be deleted.',
);
}
await deleteWorkspaceRows(ctx, job);
return { success: true };
},
});
export const countOldWorkspaces = query({
args: { olderThanDays: v.optional(v.number()) },
handler: async (ctx, { olderThanDays }) => {
const ownerId = await getRequiredUserId(ctx);
const cutoff =
olderThanDays && olderThanDays > 0
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
: Number.POSITIVE_INFINITY;
const jobs = await ctx.db
.query('agentJobs')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
return jobs.filter(
(job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff,
).length;
},
});
export const deleteOldWorkspaces = mutation({
args: {
olderThanDays: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, { olderThanDays, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const cutoff =
olderThanDays && olderThanDays > 0
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
: Number.POSITIVE_INFINITY;
const max = Math.min(Math.max(limit ?? 50, 1), 100);
const jobs = await ctx.db
.query('agentJobs')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
const deletable = jobs
.filter((job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff)
.sort((a, b) => a.updatedAt - b.updatedAt)
.slice(0, max);
for (const job of deletable) {
await deleteWorkspaceRows(ctx, job);
}
return { deleted: deletable.length };
},
});
export const claimNextInternal = internalMutation({
args: { workerId: v.string() },
handler: async (ctx, { workerId }) => {
@@ -867,6 +1132,138 @@ export const markWorkspaceActive = mutation({
},
});
export const setRuntimeSession = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
agentRuntimeMode,
opencodeSessionId: v.optional(v.string()),
codexSessionId: v.optional(v.string()),
containerId: 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.');
}
await ctx.db.patch(args.jobId, {
agentRuntimeMode: args.agentRuntimeMode,
opencodeSessionId: optionalText(args.opencodeSessionId),
codexSessionId: optionalText(args.codexSessionId),
containerId: optionalText(args.containerId),
updatedAt: Date.now(),
});
return { success: true };
},
});
export const setCodexSessionId = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
codexSessionId: 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.');
}
await ctx.db.patch(args.jobId, {
codexSessionId: optionalText(args.codexSessionId),
agentRuntimeMode: 'codex_exec',
updatedAt: Date.now(),
});
return { success: true };
},
});
export const createInteractionRequest = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
runtime: interactionRuntime,
externalRequestId: v.string(),
kind: interactionKind,
title: v.string(),
body: v.string(),
options: v.optional(v.array(v.string())),
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 existing = (
await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
.collect()
).find((request) => request.externalRequestId === args.externalRequestId);
const record = {
runtime: args.runtime,
externalRequestId: args.externalRequestId,
kind: args.kind,
title: args.title,
body: args.body,
options: args.options,
metadata: args.metadata,
status: 'pending' as const,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, record);
return existing._id;
}
const requestId = await ctx.db.insert('agentInteractionRequests', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
...record,
createdAt: now,
});
await ctx.db.patch(args.jobId, {
status: 'running',
updatedAt: now,
});
return requestId;
},
});
export const patchInteractionRequest = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
interactionId: v.id('agentInteractionRequests'),
status: interactionStatus,
response: v.optional(v.string()),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const interaction = await ctx.db.get(args.interactionId);
if (!interaction) throw new ConvexError('Interaction request not found.');
const job = await ctx.db.get(interaction.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
await ctx.db.patch(args.interactionId, {
status: args.status,
response: optionalText(args.response),
metadata: args.metadata,
updatedAt: Date.now(),
});
return { success: true };
},
});
export const markWorkspaceStopped = mutation({
args: {
workerToken: v.string(),
+50
View File
@@ -524,6 +524,14 @@ const applicationTables = {
baseBranch: v.string(),
workBranch: v.string(),
opencodeSessionId: v.optional(v.string()),
codexSessionId: v.optional(v.string()),
agentRuntimeMode: v.optional(
v.union(
v.literal('opencode_server'),
v.literal('codex_exec'),
v.literal('legacy_cli'),
),
),
containerId: v.optional(v.string()),
workspaceUrl: v.optional(v.string()),
workspaceExpiresAt: v.optional(v.number()),
@@ -587,6 +595,48 @@ const applicationTables = {
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentWorkspaceUiStates: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
openFilePaths: v.array(v.string()),
activeFilePath: v.optional(v.string()),
vimEnabled: v.boolean(),
expandedDirectoryPaths: v.array(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentInteractionRequests: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
runtime: v.union(v.literal('opencode'), v.literal('codex')),
externalRequestId: v.string(),
kind: v.union(
v.literal('question'),
v.literal('permission'),
v.literal('tool_confirmation'),
),
title: v.string(),
body: v.string(),
options: v.optional(v.array(v.string())),
status: v.union(
v.literal('pending'),
v.literal('answered'),
v.literal('approved'),
v.literal('rejected'),
v.literal('expired'),
),
response: v.optional(v.string()),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_job_status', ['jobId', 'status'])
.index('by_owner', ['ownerId']),
agentWorkspaceChanges: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),