Add features & update project
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { convexTest } from 'convex-test';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { Id } from '../../convex/_generated/dataModel.js';
|
||||
import { api } from '../../convex/_generated/api.js';
|
||||
import schema from '../../convex/schema';
|
||||
|
||||
@@ -33,6 +34,60 @@ const spoonInput = {
|
||||
productionRefStrategy: 'default_branch' as const,
|
||||
};
|
||||
|
||||
const createAgentJob = async (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
args: {
|
||||
ownerId: Id<'users'>;
|
||||
spoonId: Id<'spoons'>;
|
||||
status: 'running' | 'failed' | 'cancelled';
|
||||
workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired';
|
||||
},
|
||||
) =>
|
||||
await t.mutation(async (ctx) => {
|
||||
const now = Date.now();
|
||||
const requestId = await ctx.db.insert('agentRequests', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId: args.ownerId,
|
||||
prompt: 'Clean this workspace',
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const jobId = await ctx.db.insert('agentJobs', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId: args.ownerId,
|
||||
agentRequestId: requestId,
|
||||
status: args.status,
|
||||
prompt: 'Clean this workspace',
|
||||
runtime: 'opencode',
|
||||
workspaceStatus: args.workspaceStatus,
|
||||
baseBranch: 'main',
|
||||
workBranch: 'spoon/test',
|
||||
forkOwner: 'team',
|
||||
forkRepo: 'editor-spoon',
|
||||
forkUrl: 'https://git.example.com/team/editor-spoon',
|
||||
upstreamOwner: 'upstream',
|
||||
upstreamRepo: 'editor',
|
||||
selectedSecretIds: [],
|
||||
model: 'openai/gpt-5.1-codex',
|
||||
reasoningEffort: 'medium',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(requestId, { agentJobId: jobId });
|
||||
await ctx.db.insert('agentJobMessages', {
|
||||
jobId,
|
||||
spoonId: args.spoonId,
|
||||
ownerId: args.ownerId,
|
||||
role: 'assistant',
|
||||
content: 'done',
|
||||
status: 'completed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return jobId;
|
||||
});
|
||||
|
||||
describe('convex-test harness', () => {
|
||||
test('boots and executes against the project schema', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
@@ -89,4 +144,71 @@ describe('convex-test harness', () => {
|
||||
}),
|
||||
).rejects.toThrow('Spoon not found.');
|
||||
});
|
||||
|
||||
test('deletes terminal workspaces and associated rows', 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,
|
||||
spoonInput,
|
||||
);
|
||||
const jobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'failed',
|
||||
workspaceStatus: 'failed',
|
||||
});
|
||||
|
||||
await authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId });
|
||||
|
||||
const job = await t.run(async (ctx) => await ctx.db.get(jobId));
|
||||
const messages = await t.run(
|
||||
async (ctx) =>
|
||||
await ctx.db
|
||||
.query('agentJobMessages')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||
.collect(),
|
||||
);
|
||||
expect(job).toBeNull();
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not delete active workspaces', 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,
|
||||
spoonInput,
|
||||
);
|
||||
const jobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'running',
|
||||
workspaceStatus: 'active',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
|
||||
).rejects.toThrow('Only stopped, cancelled, failed, or expired workspaces');
|
||||
});
|
||||
|
||||
test('does not delete another user’s workspace', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
|
||||
const otherId = (await createUser(t, 'other@example.com')) as Id<'users'>;
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
spoonInput,
|
||||
);
|
||||
const jobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'cancelled',
|
||||
workspaceStatus: 'stopped',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
|
||||
).rejects.toThrow('Agent job not found.');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user