import { convexTest } from 'convex-test'; import { beforeAll, describe, expect, test } from 'vitest'; import { api } from '../../convex/_generated/api.js'; import schema from '../../convex/schema'; const modules = import.meta.glob('../../convex/**/*.*s'); const spoonInput = { name: 'Editor Spoon', provider: 'gitea' as const, upstreamOwner: 'upstream', upstreamRepo: 'editor', upstreamDefaultBranch: 'main', upstreamUrl: 'https://git.example.com/upstream/editor', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://git.example.com/team/editor-spoon', visibility: 'private' as const, maintenanceMode: 'watch' as const, syncCadence: 'daily' as const, productionRefStrategy: 'default_branch' as const, }; const seedJob = async ( t: ReturnType, status: 'cancelled' | 'running', ) => await t.mutation(async (ctx) => { const now = Date.now(); const ownerId = await ctx.db.insert('users', { email: 'a@b.c', name: 'A', }); const spoonId = await ctx.db.insert('spoons', { ...spoonInput, ownerId, status: 'active', createdAt: now, updatedAt: now, }); const requestId = await ctx.db.insert('agentRequests', { spoonId, ownerId, prompt: 'x', status: 'running', createdAt: now, updatedAt: now, }); return await ctx.db.insert('agentJobs', { spoonId, ownerId, agentRequestId: requestId, status, prompt: 'x', runtime: 'opencode', baseBranch: 'main', workBranch: 'spoon/x', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://git.example.com/team/editor-spoon', upstreamOwner: 'upstream', upstreamRepo: 'editor', selectedSecretIds: [], model: '', reasoningEffort: 'medium', claimedBy: 'worker-1', createdAt: now, updatedAt: now, }); }); beforeAll(() => { process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; }); describe('updateStatus terminal guard', () => { test('ignores writes to a cancelled job', async () => { const t = convexTest(schema, modules); const jobId = await seedJob(t, 'cancelled'); const result = await t.mutation(api.agentJobs.updateStatus, { workerToken: 'test-worker-token', workerId: 'worker-1', jobId, status: 'running', }); expect(result).toEqual({ success: true, ignored: true }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(job?.status).toBe('cancelled'); }); test('accepts writes to a running job', async () => { const t = convexTest(schema, modules); const jobId = await seedJob(t, 'running'); const result = await t.mutation(api.agentJobs.updateStatus, { workerToken: 'test-worker-token', workerId: 'worker-1', jobId, status: 'checks_running', }); expect(result).toEqual({ success: true }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(job?.status).toBe('checks_running'); }); }); describe('heartbeatWorkspace cancellation propagation', () => { test('reports cancellation while refreshing the workspace heartbeat', async () => { const t = convexTest(schema, modules); const jobId = await seedJob(t, 'cancelled'); const result = await t.mutation(api.agentJobs.heartbeatWorkspace, { workerToken: 'test-worker-token', workerId: 'worker-1', jobId, }); expect(result).toEqual({ success: true, cancelRequested: true }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(job?.status).toBe('cancelled'); expect(job?.workspaceStatus).toBe('active'); expect(job?.lastHeartbeatAt).toBeTypeOf('number'); }); test('reports no cancellation for an active job', async () => { const t = convexTest(schema, modules); const jobId = await seedJob(t, 'running'); const result = await t.mutation(api.agentJobs.heartbeatWorkspace, { workerToken: 'test-worker-token', workerId: 'worker-1', jobId, }); expect(result).toEqual({ success: true, cancelRequested: false }); }); });