Files
stack/apps/api/src/job-events/job-events.service.ts
T
jason.woltjeandClaude Opus 4.5 efe624e2c1 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 <[email protected]>
2026-02-01 21:16:23 -06:00

198 lines
4.7 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateEventDto, QueryEventsDto } from "./dto";
import {
JOB_CREATED,
JOB_STARTED,
JOB_COMPLETED,
JOB_FAILED,
STEP_STARTED,
STEP_COMPLETED,
AI_TOKENS_USED,
} from "./event-types";
/**
* Service for managing job events
* Events are immutable once created and provide an audit log of all job activities
*/
@Injectable()
export class JobEventsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Emit a job event
* Events are stored immutably in PostgreSQL
*/
async emitEvent(jobId: string, createEventDto: CreateEventDto) {
// Verify job exists
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: { id: true },
});
if (!job) {
throw new NotFoundException(`RunnerJob with ID ${jobId} not found`);
}
// Verify step exists if stepId is provided
if (createEventDto.stepId) {
const step = await this.prisma.jobStep.findUnique({
where: { id: createEventDto.stepId },
select: { id: true },
});
if (!step) {
throw new NotFoundException(`JobStep with ID ${createEventDto.stepId} not found`);
}
}
// Build event data
const data: Prisma.JobEventCreateInput = {
job: { connect: { id: jobId } },
type: createEventDto.type,
timestamp: new Date(),
actor: createEventDto.actor,
payload: createEventDto.payload as unknown as Prisma.InputJsonValue,
};
// Add step connection if provided
if (createEventDto.stepId) {
data.step = { connect: { id: createEventDto.stepId } };
}
// Create and return the event
return this.prisma.jobEvent.create({ data });
}
/**
* Get events for a specific job with optional filtering
*/
async getEventsByJobId(jobId: string, query: QueryEventsDto) {
// Verify job exists
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: { id: true },
});
if (!job) {
throw new NotFoundException(`RunnerJob with ID ${jobId} not found`);
}
const page = query.page ?? 1;
const limit = query.limit ?? 50;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.JobEventWhereInput = { jobId };
if (query.type) {
where.type = query.type;
}
if (query.stepId) {
where.stepId = query.stepId;
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.jobEvent.findMany({
where,
orderBy: { timestamp: "asc" },
skip,
take: limit,
}),
this.prisma.jobEvent.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Convenience method: Emit job.created event
*/
async emitJobCreated(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_CREATED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit job.started event
*/
async emitJobStarted(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_STARTED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit job.completed event
*/
async emitJobCompleted(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_COMPLETED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit job.failed event
*/
async emitJobFailed(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_FAILED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit step.started event
*/
async emitStepStarted(jobId: string, stepId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: STEP_STARTED,
actor: "system",
payload,
stepId,
});
}
/**
* Convenience method: Emit step.completed event
*/
async emitStepCompleted(jobId: string, stepId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: STEP_COMPLETED,
actor: "system",
payload,
stepId,
});
}
/**
* Convenience method: Emit ai.tokens_used event
*/
async emitAiTokensUsed(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: AI_TOKENS_USED,
actor: "system",
payload,
});
}
}