feat(#168): Implement job steps tracking
Implement JobStepsModule for granular step tracking within runner jobs. Features: - Create and track job steps (SETUP, EXECUTION, VALIDATION, CLEANUP) - Track step status transitions (PENDING → RUNNING → COMPLETED/FAILED) - Record token usage for AI_ACTION steps - Calculate step duration automatically - GET endpoints for listing and retrieving steps Implementation: - JobStepsService: CRUD operations, status tracking, duration calculation - JobStepsController: GET /runner-jobs/:jobId/steps endpoints - DTOs: CreateStepDto, UpdateStepDto with validation - Full unit test coverage (16 tests) Quality gates: - Build: ✅ Passed - Lint: ✅ Passed - Tests: ✅ 16/16 passed - Coverage: ✅ 100% statements, 100% functions, 100% lines, 83.33% branches Also fixed pre-existing TypeScript strict mode issue in job-events DTO. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
26
apps/api/src/job-steps/dto/create-step.dto.ts
Normal file
26
apps/api/src/job-steps/dto/create-step.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { JobStepPhase, JobStepType, JobStepStatus } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsInt, IsOptional, MinLength, MaxLength, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new job step
|
||||
*/
|
||||
export class CreateStepDto {
|
||||
@IsInt({ message: "ordinal must be an integer" })
|
||||
@Min(0, { message: "ordinal must be at least 0" })
|
||||
ordinal!: number;
|
||||
|
||||
@IsEnum(JobStepPhase, { message: "phase must be a valid JobStepPhase" })
|
||||
phase!: JobStepPhase;
|
||||
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(200, { message: "name must not exceed 200 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsEnum(JobStepType, { message: "type must be a valid JobStepType" })
|
||||
type!: JobStepType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(JobStepStatus, { message: "status must be a valid JobStepStatus" })
|
||||
status?: JobStepStatus;
|
||||
}
|
||||
2
apps/api/src/job-steps/dto/index.ts
Normal file
2
apps/api/src/job-steps/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./create-step.dto";
|
||||
export * from "./update-step.dto";
|
||||
25
apps/api/src/job-steps/dto/update-step.dto.ts
Normal file
25
apps/api/src/job-steps/dto/update-step.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { JobStepStatus } from "@prisma/client";
|
||||
import { IsEnum, IsString, IsOptional, IsInt, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating a job step
|
||||
*/
|
||||
export class UpdateStepDto {
|
||||
@IsOptional()
|
||||
@IsEnum(JobStepStatus, { message: "status must be a valid JobStepStatus" })
|
||||
status?: JobStepStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "output must be a string" })
|
||||
output?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt({ message: "tokensInput must be an integer" })
|
||||
@Min(0, { message: "tokensInput must be at least 0" })
|
||||
tokensInput?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt({ message: "tokensOutput must be an integer" })
|
||||
@Min(0, { message: "tokensOutput must be at least 0" })
|
||||
tokensOutput?: number;
|
||||
}
|
||||
4
apps/api/src/job-steps/index.ts
Normal file
4
apps/api/src/job-steps/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./job-steps.module";
|
||||
export * from "./job-steps.service";
|
||||
export * from "./job-steps.controller";
|
||||
export * from "./dto";
|
||||
147
apps/api/src/job-steps/job-steps.controller.spec.ts
Normal file
147
apps/api/src/job-steps/job-steps.controller.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { JobStepsController } from "./job-steps.controller";
|
||||
import { JobStepsService } from "./job-steps.service";
|
||||
import { JobStepPhase, JobStepType, JobStepStatus } from "@prisma/client";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
|
||||
describe("JobStepsController", () => {
|
||||
let controller: JobStepsController;
|
||||
let service: JobStepsService;
|
||||
|
||||
const mockJobStepsService = {
|
||||
findAllByJob: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
startStep: vi.fn(),
|
||||
completeStep: vi.fn(),
|
||||
failStep: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn((context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
const mockWorkspaceGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockPermissionGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [JobStepsController],
|
||||
providers: [
|
||||
{
|
||||
provide: JobStepsService,
|
||||
useValue: mockJobStepsService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue(mockWorkspaceGuard)
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue(mockPermissionGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<JobStepsController>(JobStepsController);
|
||||
service = module.get<JobStepsService>(JobStepsService);
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all steps for a job", async () => {
|
||||
const jobId = "job-123";
|
||||
const mockSteps = [
|
||||
{
|
||||
id: "step-1",
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Cloned successfully",
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: 5000,
|
||||
},
|
||||
{
|
||||
id: "step-2",
|
||||
jobId,
|
||||
ordinal: 2,
|
||||
phase: JobStepPhase.EXECUTION,
|
||||
name: "Run tests",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
},
|
||||
];
|
||||
|
||||
mockJobStepsService.findAllByJob.mockResolvedValue(mockSteps);
|
||||
|
||||
const result = await controller.findAll(jobId);
|
||||
|
||||
expect(result).toEqual(mockSteps);
|
||||
expect(service.findAllByJob).toHaveBeenCalledWith(jobId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a single step by ID", async () => {
|
||||
const jobId = "job-123";
|
||||
const stepId = "step-123";
|
||||
|
||||
const mockStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Cloned successfully",
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: 5000,
|
||||
};
|
||||
|
||||
mockJobStepsService.findOne.mockResolvedValue(mockStep);
|
||||
|
||||
const result = await controller.findOne(jobId, stepId);
|
||||
|
||||
expect(result).toEqual(mockStep);
|
||||
expect(service.findOne).toHaveBeenCalledWith(stepId, jobId);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
apps/api/src/job-steps/job-steps.controller.ts
Normal file
42
apps/api/src/job-steps/job-steps.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
||||
import { JobStepsService } from "./job-steps.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Permission, RequirePermission } from "../common/decorators";
|
||||
|
||||
/**
|
||||
* Controller for job steps endpoints
|
||||
* All endpoints require authentication and workspace context
|
||||
*
|
||||
* Guards are applied in order:
|
||||
* 1. AuthGuard - Verifies user authentication
|
||||
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
||||
* 3. PermissionGuard - Checks role-based permissions
|
||||
*/
|
||||
@Controller("runner-jobs/:jobId/steps")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class JobStepsController {
|
||||
constructor(private readonly jobStepsService: JobStepsService) {}
|
||||
|
||||
/**
|
||||
* GET /api/runner-jobs/:jobId/steps
|
||||
* Get all steps for a job
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(@Param("jobId") jobId: string) {
|
||||
return this.jobStepsService.findAllByJob(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/runner-jobs/:jobId/steps/:stepId
|
||||
* Get a single step by ID
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":stepId")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(@Param("jobId") jobId: string, @Param("stepId") stepId: string) {
|
||||
return this.jobStepsService.findOne(stepId, jobId);
|
||||
}
|
||||
}
|
||||
18
apps/api/src/job-steps/job-steps.module.ts
Normal file
18
apps/api/src/job-steps/job-steps.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JobStepsController } from "./job-steps.controller";
|
||||
import { JobStepsService } from "./job-steps.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
/**
|
||||
* Job Steps Module
|
||||
*
|
||||
* Provides granular step tracking within runner jobs.
|
||||
* Tracks step status transitions, token usage, and duration.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [JobStepsController],
|
||||
providers: [JobStepsService],
|
||||
exports: [JobStepsService],
|
||||
})
|
||||
export class JobStepsModule {}
|
||||
511
apps/api/src/job-steps/job-steps.service.spec.ts
Normal file
511
apps/api/src/job-steps/job-steps.service.spec.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { JobStepsService } from "./job-steps.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { JobStepPhase, JobStepType, JobStepStatus } from "@prisma/client";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { CreateStepDto, UpdateStepDto } from "./dto";
|
||||
|
||||
describe("JobStepsService", () => {
|
||||
let service: JobStepsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
jobStep: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
runnerJob: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
JobStepsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<JobStepsService>(JobStepsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a job step", async () => {
|
||||
const jobId = "job-123";
|
||||
const createDto: CreateStepDto = {
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repository",
|
||||
type: JobStepType.COMMAND,
|
||||
};
|
||||
|
||||
const mockStep = {
|
||||
id: "step-123",
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repository",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.PENDING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.create.mockResolvedValue(mockStep);
|
||||
|
||||
const result = await service.create(jobId, createDto);
|
||||
|
||||
expect(result).toEqual(mockStep);
|
||||
expect(prisma.jobStep.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repository",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.PENDING,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use provided status when creating step", async () => {
|
||||
const jobId = "job-123";
|
||||
const createDto: CreateStepDto = {
|
||||
ordinal: 2,
|
||||
phase: JobStepPhase.EXECUTION,
|
||||
name: "Run tests",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
};
|
||||
|
||||
const mockStep = {
|
||||
id: "step-124",
|
||||
jobId,
|
||||
ordinal: 2,
|
||||
phase: JobStepPhase.EXECUTION,
|
||||
name: "Run tests",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.create.mockResolvedValue(mockStep);
|
||||
|
||||
const result = await service.create(jobId, createDto);
|
||||
|
||||
expect(result).toEqual(mockStep);
|
||||
expect(prisma.jobStep.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
ordinal: 2,
|
||||
phase: JobStepPhase.EXECUTION,
|
||||
name: "Run tests",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAllByJob", () => {
|
||||
it("should return all steps for a job ordered by ordinal", async () => {
|
||||
const jobId = "job-123";
|
||||
|
||||
const mockSteps = [
|
||||
{
|
||||
id: "step-1",
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Cloned successfully",
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: 5000,
|
||||
},
|
||||
{
|
||||
id: "step-2",
|
||||
jobId,
|
||||
ordinal: 2,
|
||||
phase: JobStepPhase.EXECUTION,
|
||||
name: "Run tests",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.jobStep.findMany.mockResolvedValue(mockSteps);
|
||||
|
||||
const result = await service.findAllByJob(jobId);
|
||||
|
||||
expect(result).toEqual(mockSteps);
|
||||
expect(prisma.jobStep.findMany).toHaveBeenCalledWith({
|
||||
where: { jobId },
|
||||
orderBy: { ordinal: "asc" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a single step by ID", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
|
||||
const mockStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Cloned successfully",
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: 5000,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(mockStep);
|
||||
|
||||
const result = await service.findOne(stepId, jobId);
|
||||
|
||||
expect(result).toEqual(mockStep);
|
||||
expect(prisma.jobStep.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: stepId, jobId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when step not found", async () => {
|
||||
const stepId = "step-999";
|
||||
const jobId = "job-123";
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(stepId, jobId)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne(stepId, jobId)).rejects.toThrow(
|
||||
`JobStep with ID ${stepId} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update step status", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
const updateDto: UpdateStepDto = {
|
||||
status: JobStepStatus.COMPLETED,
|
||||
};
|
||||
|
||||
const existingStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
const updatedStep = {
|
||||
...existingStep,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: 5000,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep);
|
||||
mockPrismaService.jobStep.update.mockResolvedValue(updatedStep);
|
||||
|
||||
const result = await service.update(stepId, jobId, updateDto);
|
||||
|
||||
expect(result).toEqual(updatedStep);
|
||||
expect(prisma.jobStep.update).toHaveBeenCalledWith({
|
||||
where: { id: stepId, jobId },
|
||||
data: { status: JobStepStatus.COMPLETED },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update step with output and token usage", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
const updateDto: UpdateStepDto = {
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Analysis complete",
|
||||
tokensInput: 1000,
|
||||
tokensOutput: 500,
|
||||
};
|
||||
|
||||
const existingStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 2,
|
||||
phase: JobStepPhase.EXECUTION,
|
||||
name: "AI Analysis",
|
||||
type: JobStepType.AI_ACTION,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
const updatedStep = {
|
||||
...existingStep,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Analysis complete",
|
||||
tokensInput: 1000,
|
||||
tokensOutput: 500,
|
||||
completedAt: new Date("2024-01-01T10:00:10Z"),
|
||||
durationMs: 10000,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep);
|
||||
mockPrismaService.jobStep.update.mockResolvedValue(updatedStep);
|
||||
|
||||
const result = await service.update(stepId, jobId, updateDto);
|
||||
|
||||
expect(result).toEqual(updatedStep);
|
||||
expect(prisma.jobStep.update).toHaveBeenCalledWith({
|
||||
where: { id: stepId, jobId },
|
||||
data: {
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Analysis complete",
|
||||
tokensInput: 1000,
|
||||
tokensOutput: 500,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when step not found", async () => {
|
||||
const stepId = "step-999";
|
||||
const jobId = "job-123";
|
||||
const updateDto: UpdateStepDto = {
|
||||
status: JobStepStatus.COMPLETED,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(stepId, jobId, updateDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startStep", () => {
|
||||
it("should mark step as running and set startedAt", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
|
||||
const existingStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.PENDING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
const startedStep = {
|
||||
...existingStep,
|
||||
status: JobStepStatus.RUNNING,
|
||||
startedAt: new Date("2024-01-01T10:00:00Z"),
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep);
|
||||
mockPrismaService.jobStep.update.mockResolvedValue(startedStep);
|
||||
|
||||
const result = await service.startStep(stepId, jobId);
|
||||
|
||||
expect(result).toEqual(startedStep);
|
||||
expect(prisma.jobStep.update).toHaveBeenCalledWith({
|
||||
where: { id: stepId, jobId },
|
||||
data: {
|
||||
status: JobStepStatus.RUNNING,
|
||||
startedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeStep", () => {
|
||||
it("should mark step as completed and calculate duration", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
|
||||
const startTime = new Date("2024-01-01T10:00:00Z");
|
||||
const existingStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: startTime,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
const completedStep = {
|
||||
...existingStep,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Success",
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: 5000,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep);
|
||||
mockPrismaService.jobStep.update.mockResolvedValue(completedStep);
|
||||
|
||||
const result = await service.completeStep(stepId, jobId, "Success");
|
||||
|
||||
expect(result).toEqual(completedStep);
|
||||
expect(prisma.jobStep.update).toHaveBeenCalledWith({
|
||||
where: { id: stepId, jobId },
|
||||
data: {
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Success",
|
||||
completedAt: expect.any(Date),
|
||||
durationMs: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle step without startedAt by setting durationMs to null", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
|
||||
const existingStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.SETUP,
|
||||
name: "Clone repo",
|
||||
type: JobStepType.COMMAND,
|
||||
status: JobStepStatus.PENDING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
const completedStep = {
|
||||
...existingStep,
|
||||
status: JobStepStatus.COMPLETED,
|
||||
output: "Success",
|
||||
completedAt: new Date("2024-01-01T10:00:05Z"),
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep);
|
||||
mockPrismaService.jobStep.update.mockResolvedValue(completedStep);
|
||||
|
||||
const result = await service.completeStep(stepId, jobId, "Success");
|
||||
|
||||
expect(result.durationMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failStep", () => {
|
||||
it("should mark step as failed with error output", async () => {
|
||||
const stepId = "step-123";
|
||||
const jobId = "job-123";
|
||||
const error = "Command failed with exit code 1";
|
||||
|
||||
const startTime = new Date("2024-01-01T10:00:00Z");
|
||||
const existingStep = {
|
||||
id: stepId,
|
||||
jobId,
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.VALIDATION,
|
||||
name: "Run tests",
|
||||
type: JobStepType.GATE,
|
||||
status: JobStepStatus.RUNNING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: startTime,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
const failedStep = {
|
||||
...existingStep,
|
||||
status: JobStepStatus.FAILED,
|
||||
output: error,
|
||||
completedAt: new Date("2024-01-01T10:00:03Z"),
|
||||
durationMs: 3000,
|
||||
};
|
||||
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep);
|
||||
mockPrismaService.jobStep.update.mockResolvedValue(failedStep);
|
||||
|
||||
const result = await service.failStep(stepId, jobId, error);
|
||||
|
||||
expect(result).toEqual(failedStep);
|
||||
expect(prisma.jobStep.update).toHaveBeenCalledWith({
|
||||
where: { id: stepId, jobId },
|
||||
data: {
|
||||
status: JobStepStatus.FAILED,
|
||||
output: error,
|
||||
completedAt: expect.any(Date),
|
||||
durationMs: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
148
apps/api/src/job-steps/job-steps.service.ts
Normal file
148
apps/api/src/job-steps/job-steps.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, JobStepStatus } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { CreateStepDto, UpdateStepDto } from "./dto";
|
||||
|
||||
/**
|
||||
* Service for managing job steps
|
||||
*/
|
||||
@Injectable()
|
||||
export class JobStepsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new job step
|
||||
*/
|
||||
async create(jobId: string, createStepDto: CreateStepDto) {
|
||||
const data: Prisma.JobStepCreateInput = {
|
||||
job: { connect: { id: jobId } },
|
||||
ordinal: createStepDto.ordinal,
|
||||
phase: createStepDto.phase,
|
||||
name: createStepDto.name,
|
||||
type: createStepDto.type,
|
||||
status: createStepDto.status ?? JobStepStatus.PENDING,
|
||||
};
|
||||
|
||||
return this.prisma.jobStep.create({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all steps for a job, ordered by ordinal
|
||||
*/
|
||||
async findAllByJob(jobId: string) {
|
||||
return this.prisma.jobStep.findMany({
|
||||
where: { jobId },
|
||||
orderBy: { ordinal: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single step by ID
|
||||
*/
|
||||
async findOne(id: string, jobId: string) {
|
||||
const step = await this.prisma.jobStep.findUnique({
|
||||
where: { id, jobId },
|
||||
});
|
||||
|
||||
if (!step) {
|
||||
throw new NotFoundException(`JobStep with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a job step
|
||||
*/
|
||||
async update(id: string, jobId: string, updateStepDto: UpdateStepDto) {
|
||||
// Verify step exists
|
||||
await this.findOne(id, jobId);
|
||||
|
||||
const data: Prisma.JobStepUpdateInput = {};
|
||||
|
||||
if (updateStepDto.status !== undefined) {
|
||||
data.status = updateStepDto.status;
|
||||
}
|
||||
if (updateStepDto.output !== undefined) {
|
||||
data.output = updateStepDto.output;
|
||||
}
|
||||
if (updateStepDto.tokensInput !== undefined) {
|
||||
data.tokensInput = updateStepDto.tokensInput;
|
||||
}
|
||||
if (updateStepDto.tokensOutput !== undefined) {
|
||||
data.tokensOutput = updateStepDto.tokensOutput;
|
||||
}
|
||||
|
||||
return this.prisma.jobStep.update({
|
||||
where: { id, jobId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a step as running and set startedAt timestamp
|
||||
*/
|
||||
async startStep(id: string, jobId: string) {
|
||||
// Verify step exists
|
||||
await this.findOne(id, jobId);
|
||||
|
||||
return this.prisma.jobStep.update({
|
||||
where: { id, jobId },
|
||||
data: {
|
||||
status: JobStepStatus.RUNNING,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a step as completed, set output, and calculate duration
|
||||
*/
|
||||
async completeStep(id: string, jobId: string, output?: string) {
|
||||
// Verify step exists and get startedAt
|
||||
const existingStep = await this.findOne(id, jobId);
|
||||
|
||||
const completedAt = new Date();
|
||||
const durationMs = existingStep.startedAt
|
||||
? completedAt.getTime() - existingStep.startedAt.getTime()
|
||||
: null;
|
||||
|
||||
const data: Prisma.JobStepUpdateInput = {
|
||||
status: JobStepStatus.COMPLETED,
|
||||
completedAt,
|
||||
durationMs,
|
||||
};
|
||||
|
||||
if (output !== undefined) {
|
||||
data.output = output;
|
||||
}
|
||||
|
||||
return this.prisma.jobStep.update({
|
||||
where: { id, jobId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a step as failed, set error output, and calculate duration
|
||||
*/
|
||||
async failStep(id: string, jobId: string, error: string) {
|
||||
// Verify step exists and get startedAt
|
||||
const existingStep = await this.findOne(id, jobId);
|
||||
|
||||
const completedAt = new Date();
|
||||
const durationMs = existingStep.startedAt
|
||||
? completedAt.getTime() - existingStep.startedAt.getTime()
|
||||
: null;
|
||||
|
||||
return this.prisma.jobStep.update({
|
||||
where: { id, jobId },
|
||||
data: {
|
||||
status: JobStepStatus.FAILED,
|
||||
output: error,
|
||||
completedAt,
|
||||
durationMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user