feat(#167): Implement Runner jobs CRUD and queue submission

Implements runner-jobs module for job lifecycle management and queue submission.

Changes:
- Created RunnerJobsModule with service, controller, and DTOs
- Implemented job creation with BullMQ queue submission
- Implemented job listing with filters (status, type, agentTaskId)
- Implemented job detail retrieval with steps and events
- Implemented cancel operation for pending/queued jobs
- Implemented retry operation for failed jobs
- Added comprehensive unit tests (24 tests, 100% coverage)
- Integrated with BullMQ for async job processing
- Integrated with Prisma for database operations
- Followed existing CRUD patterns from tasks/events modules

API Endpoints:
- POST /runner-jobs - Create and queue a new job
- GET /runner-jobs - List jobs (with filters)
- GET /runner-jobs/:id - Get job details
- POST /runner-jobs/:id/cancel - Cancel a running job
- POST /runner-jobs/:id/retry - Retry a failed job

Quality Gates:
- Typecheck:  PASSED
- Lint:  PASSED
- Build:  PASSED
- Tests:  PASSED (24/24 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:09:03 -06:00
parent a2cd614e87
commit 7102b4a1d2
73 changed files with 2498 additions and 45 deletions

View File

@@ -0,0 +1,527 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { RunnerJobStatus } from "@prisma/client";
import { NotFoundException, BadRequestException } from "@nestjs/common";
import { CreateJobDto, QueryJobsDto } from "./dto";
describe("RunnerJobsService", () => {
let service: RunnerJobsService;
let prisma: PrismaService;
let bullMq: BullMqService;
const mockPrismaService = {
runnerJob: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
};
const mockBullMqService = {
addJob: vi.fn(),
getQueue: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RunnerJobsService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: BullMqService,
useValue: mockBullMqService,
},
],
}).compile();
service = module.get<RunnerJobsService>(RunnerJobsService);
prisma = module.get<PrismaService>(PrismaService);
bullMq = module.get<BullMqService>(BullMqService);
// Clear all mocks before each test
vi.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("create", () => {
it("should create a job and add it to BullMQ queue", async () => {
const workspaceId = "workspace-123";
const createDto: CreateJobDto = {
type: "git-status",
priority: 5,
data: { repo: "test-repo" },
};
const mockJob = {
id: "job-123",
workspaceId,
type: "git-status",
status: RunnerJobStatus.PENDING,
priority: 5,
progressPercent: 0,
result: null,
error: null,
createdAt: new Date(),
startedAt: null,
completedAt: null,
agentTaskId: null,
};
const mockBullMqJob = {
id: "bull-job-123",
name: "runner-job",
};
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
mockBullMqService.addJob.mockResolvedValue(mockBullMqJob);
const result = await service.create(workspaceId, createDto);
expect(result).toEqual(mockJob);
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
data: {
workspace: { connect: { id: workspaceId } },
type: "git-status",
priority: 5,
status: RunnerJobStatus.PENDING,
progressPercent: 0,
result: { repo: "test-repo" },
},
});
expect(bullMq.addJob).toHaveBeenCalledWith(
"mosaic-jobs-runner",
"runner-job",
{
jobId: "job-123",
workspaceId,
type: "git-status",
data: { repo: "test-repo" },
},
{ priority: 5 }
);
});
it("should create a job with agentTaskId if provided", async () => {
const workspaceId = "workspace-123";
const createDto: CreateJobDto = {
type: "code-task",
agentTaskId: "agent-task-123",
priority: 8,
};
const mockJob = {
id: "job-456",
workspaceId,
type: "code-task",
status: RunnerJobStatus.PENDING,
priority: 8,
progressPercent: 0,
result: null,
error: null,
createdAt: new Date(),
startedAt: null,
completedAt: null,
agentTaskId: "agent-task-123",
};
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
mockBullMqService.addJob.mockResolvedValue({ id: "bull-job-456" });
const result = await service.create(workspaceId, createDto);
expect(result).toEqual(mockJob);
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
data: {
workspace: { connect: { id: workspaceId } },
type: "code-task",
priority: 8,
status: RunnerJobStatus.PENDING,
progressPercent: 0,
agentTask: { connect: { id: "agent-task-123" } },
},
});
});
it("should use default priority of 5 if not provided", async () => {
const workspaceId = "workspace-123";
const createDto: CreateJobDto = {
type: "priority-calc",
};
const mockJob = {
id: "job-789",
workspaceId,
type: "priority-calc",
status: RunnerJobStatus.PENDING,
priority: 5,
progressPercent: 0,
result: null,
error: null,
createdAt: new Date(),
startedAt: null,
completedAt: null,
agentTaskId: null,
};
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
mockBullMqService.addJob.mockResolvedValue({ id: "bull-job-789" });
await service.create(workspaceId, createDto);
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
data: {
workspace: { connect: { id: workspaceId } },
type: "priority-calc",
priority: 5,
status: RunnerJobStatus.PENDING,
progressPercent: 0,
},
});
});
});
describe("findAll", () => {
it("should return paginated jobs with filters", async () => {
const query: QueryJobsDto = {
workspaceId: "workspace-123",
status: RunnerJobStatus.PENDING,
page: 1,
limit: 10,
};
const mockJobs = [
{
id: "job-1",
workspaceId: "workspace-123",
type: "git-status",
status: RunnerJobStatus.PENDING,
priority: 5,
progressPercent: 0,
createdAt: new Date(),
},
];
mockPrismaService.runnerJob.findMany.mockResolvedValue(mockJobs);
mockPrismaService.runnerJob.count.mockResolvedValue(1);
const result = await service.findAll(query);
expect(result).toEqual({
data: mockJobs,
meta: {
total: 1,
page: 1,
limit: 10,
totalPages: 1,
},
});
});
it("should handle multiple status filters", async () => {
const query: QueryJobsDto = {
workspaceId: "workspace-123",
status: [RunnerJobStatus.RUNNING, RunnerJobStatus.QUEUED],
page: 1,
limit: 50,
};
mockPrismaService.runnerJob.findMany.mockResolvedValue([]);
mockPrismaService.runnerJob.count.mockResolvedValue(0);
await service.findAll(query);
expect(prisma.runnerJob.findMany).toHaveBeenCalledWith({
where: {
workspaceId: "workspace-123",
status: { in: [RunnerJobStatus.RUNNING, RunnerJobStatus.QUEUED] },
},
include: {
agentTask: {
select: { id: true, title: true, status: true },
},
},
orderBy: {
createdAt: "desc",
},
skip: 0,
take: 50,
});
});
it("should filter by type", async () => {
const query: QueryJobsDto = {
workspaceId: "workspace-123",
type: "code-task",
page: 1,
limit: 50,
};
mockPrismaService.runnerJob.findMany.mockResolvedValue([]);
mockPrismaService.runnerJob.count.mockResolvedValue(0);
await service.findAll(query);
expect(prisma.runnerJob.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
workspaceId: "workspace-123",
type: "code-task",
},
})
);
});
it("should use default pagination values", async () => {
const query: QueryJobsDto = {
workspaceId: "workspace-123",
};
mockPrismaService.runnerJob.findMany.mockResolvedValue([]);
mockPrismaService.runnerJob.count.mockResolvedValue(0);
await service.findAll(query);
expect(prisma.runnerJob.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 0,
take: 50,
})
);
});
});
describe("findOne", () => {
it("should return a single job by ID", async () => {
const jobId = "job-123";
const workspaceId = "workspace-123";
const mockJob = {
id: jobId,
workspaceId,
type: "git-status",
status: RunnerJobStatus.COMPLETED,
priority: 5,
progressPercent: 100,
result: { status: "success" },
error: null,
createdAt: new Date(),
startedAt: new Date(),
completedAt: new Date(),
agentTask: null,
steps: [],
events: [],
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockJob);
const result = await service.findOne(jobId, workspaceId);
expect(result).toEqual(mockJob);
expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({
where: {
id: jobId,
workspaceId,
},
include: {
agentTask: {
select: { id: true, title: true, status: true },
},
steps: {
orderBy: { ordinal: "asc" },
},
events: {
orderBy: { timestamp: "asc" },
},
},
});
});
it("should throw NotFoundException if job not found", async () => {
const jobId = "nonexistent-job";
const workspaceId = "workspace-123";
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
await expect(service.findOne(jobId, workspaceId)).rejects.toThrow(NotFoundException);
await expect(service.findOne(jobId, workspaceId)).rejects.toThrow(
`RunnerJob with ID ${jobId} not found`
);
});
});
describe("cancel", () => {
it("should cancel a pending job", async () => {
const jobId = "job-123";
const workspaceId = "workspace-123";
const mockExistingJob = {
id: jobId,
workspaceId,
status: RunnerJobStatus.PENDING,
};
const mockUpdatedJob = {
...mockExistingJob,
status: RunnerJobStatus.CANCELLED,
completedAt: new Date(),
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
mockPrismaService.runnerJob.update.mockResolvedValue(mockUpdatedJob);
const result = await service.cancel(jobId, workspaceId);
expect(result).toEqual(mockUpdatedJob);
expect(prisma.runnerJob.update).toHaveBeenCalledWith({
where: { id: jobId, workspaceId },
data: {
status: RunnerJobStatus.CANCELLED,
completedAt: expect.any(Date),
},
});
});
it("should cancel a queued job", async () => {
const jobId = "job-456";
const workspaceId = "workspace-123";
const mockExistingJob = {
id: jobId,
workspaceId,
status: RunnerJobStatus.QUEUED,
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
mockPrismaService.runnerJob.update.mockResolvedValue({
...mockExistingJob,
status: RunnerJobStatus.CANCELLED,
});
await service.cancel(jobId, workspaceId);
expect(prisma.runnerJob.update).toHaveBeenCalled();
});
it("should throw NotFoundException if job not found", async () => {
const jobId = "nonexistent-job";
const workspaceId = "workspace-123";
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException if job is already completed", async () => {
const jobId = "job-789";
const workspaceId = "workspace-123";
const mockExistingJob = {
id: jobId,
workspaceId,
status: RunnerJobStatus.COMPLETED,
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(BadRequestException);
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(
"Cannot cancel job with status COMPLETED"
);
});
it("should throw BadRequestException if job is already cancelled", async () => {
const jobId = "job-999";
const workspaceId = "workspace-123";
const mockExistingJob = {
id: jobId,
workspaceId,
status: RunnerJobStatus.CANCELLED,
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(BadRequestException);
});
});
describe("retry", () => {
it("should retry a failed job", async () => {
const jobId = "job-123";
const workspaceId = "workspace-123";
const mockExistingJob = {
id: jobId,
workspaceId,
type: "git-status",
status: RunnerJobStatus.FAILED,
priority: 5,
result: { repo: "test-repo" },
};
const mockNewJob = {
id: "job-new",
workspaceId,
type: "git-status",
status: RunnerJobStatus.PENDING,
priority: 5,
progressPercent: 0,
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
mockPrismaService.runnerJob.create.mockResolvedValue(mockNewJob);
mockBullMqService.addJob.mockResolvedValue({ id: "bull-job-new" });
const result = await service.retry(jobId, workspaceId);
expect(result).toEqual(mockNewJob);
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
data: {
workspace: { connect: { id: workspaceId } },
type: "git-status",
priority: 5,
status: RunnerJobStatus.PENDING,
progressPercent: 0,
result: { repo: "test-repo" },
},
});
expect(bullMq.addJob).toHaveBeenCalled();
});
it("should throw NotFoundException if job not found", async () => {
const jobId = "nonexistent-job";
const workspaceId = "workspace-123";
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
await expect(service.retry(jobId, workspaceId)).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException if job is not failed", async () => {
const jobId = "job-456";
const workspaceId = "workspace-123";
const mockExistingJob = {
id: jobId,
workspaceId,
status: RunnerJobStatus.RUNNING,
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
await expect(service.retry(jobId, workspaceId)).rejects.toThrow(BadRequestException);
await expect(service.retry(jobId, workspaceId)).rejects.toThrow("Can only retry failed jobs");
});
});
});