diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 370d13a..9ac57c5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -24,6 +24,9 @@ import { ValkeyModule } from "./valkey/valkey.module"; import { BullMqModule } from "./bullmq/bullmq.module"; import { StitcherModule } from "./stitcher/stitcher.module"; import { TelemetryModule, TelemetryInterceptor } from "./telemetry"; +import { RunnerJobsModule } from "./runner-jobs/runner-jobs.module"; +import { JobEventsModule } from "./job-events/job-events.module"; +import { JobStepsModule } from "./job-steps/job-steps.module"; @Module({ imports: [ @@ -49,6 +52,9 @@ import { TelemetryModule, TelemetryInterceptor } from "./telemetry"; BrainModule, CronModule, AgentTasksModule, + RunnerJobsModule, + JobEventsModule, + JobStepsModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/job-events/dto/create-event.dto.ts b/apps/api/src/job-events/dto/create-event.dto.ts new file mode 100644 index 0000000..ba87a49 --- /dev/null +++ b/apps/api/src/job-events/dto/create-event.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional, IsObject, IsUUID, IsEnum } from "class-validator"; +import { EventType, ALL_EVENT_TYPES } from "../event-types"; + +/** + * DTO for creating a job event + */ +export class CreateEventDto { + @IsEnum(ALL_EVENT_TYPES) + type!: EventType; + + @IsString() + actor!: string; + + @IsObject() + payload!: Record; + + @IsOptional() + @IsUUID() + stepId?: string; +} diff --git a/apps/api/src/job-events/dto/index.ts b/apps/api/src/job-events/dto/index.ts new file mode 100644 index 0000000..728c9cb --- /dev/null +++ b/apps/api/src/job-events/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./create-event.dto"; +export * from "./query-events.dto"; diff --git a/apps/api/src/job-events/dto/query-events.dto.ts b/apps/api/src/job-events/dto/query-events.dto.ts new file mode 100644 index 0000000..d785bca --- /dev/null +++ b/apps/api/src/job-events/dto/query-events.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsString, IsInt, Min, Max, IsEnum } from "class-validator"; +import { Type } from "class-transformer"; +import { EventType, ALL_EVENT_TYPES } from "../event-types"; + +/** + * DTO for querying job events + */ +export class QueryEventsDto { + @IsOptional() + @IsEnum(ALL_EVENT_TYPES) + type?: EventType; + + @IsOptional() + @IsString() + stepId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/api/src/job-events/event-types.ts b/apps/api/src/job-events/event-types.ts new file mode 100644 index 0000000..f4a44f4 --- /dev/null +++ b/apps/api/src/job-events/event-types.ts @@ -0,0 +1,61 @@ +/** + * Event type constants for job events + * These events are emitted throughout the job lifecycle and stored immutably + */ + +// Job lifecycle events +export const JOB_CREATED = "job.created"; +export const JOB_QUEUED = "job.queued"; +export const JOB_STARTED = "job.started"; +export const JOB_COMPLETED = "job.completed"; +export const JOB_FAILED = "job.failed"; +export const JOB_CANCELLED = "job.cancelled"; + +// Step lifecycle events +export const STEP_STARTED = "step.started"; +export const STEP_PROGRESS = "step.progress"; +export const STEP_OUTPUT = "step.output"; +export const STEP_COMPLETED = "step.completed"; +export const STEP_FAILED = "step.failed"; + +// AI events +export const AI_TOOL_CALLED = "ai.tool_called"; +export const AI_TOKENS_USED = "ai.tokens_used"; +export const AI_ARTIFACT_CREATED = "ai.artifact_created"; + +// Gate events +export const GATE_STARTED = "gate.started"; +export const GATE_PASSED = "gate.passed"; +export const GATE_FAILED = "gate.failed"; + +/** + * All valid event types + */ +export const ALL_EVENT_TYPES = [ + // Job lifecycle + JOB_CREATED, + JOB_QUEUED, + JOB_STARTED, + JOB_COMPLETED, + JOB_FAILED, + JOB_CANCELLED, + // Step lifecycle + STEP_STARTED, + STEP_PROGRESS, + STEP_OUTPUT, + STEP_COMPLETED, + STEP_FAILED, + // AI events + AI_TOOL_CALLED, + AI_TOKENS_USED, + AI_ARTIFACT_CREATED, + // Gate events + GATE_STARTED, + GATE_PASSED, + GATE_FAILED, +] as const; + +/** + * Type for event types + */ +export type EventType = (typeof ALL_EVENT_TYPES)[number]; diff --git a/apps/api/src/job-events/index.ts b/apps/api/src/job-events/index.ts new file mode 100644 index 0000000..dbd8c2b --- /dev/null +++ b/apps/api/src/job-events/index.ts @@ -0,0 +1,5 @@ +export * from "./job-events.module"; +export * from "./job-events.service"; +export * from "./job-events.controller"; +export * from "./event-types"; +export * from "./dto"; diff --git a/apps/api/src/job-events/job-events.controller.spec.ts b/apps/api/src/job-events/job-events.controller.spec.ts new file mode 100644 index 0000000..1fcbde4 --- /dev/null +++ b/apps/api/src/job-events/job-events.controller.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { JobEventsController } from "./job-events.controller"; +import { JobEventsService } from "./job-events.service"; +import { JOB_CREATED } from "./event-types"; +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("JobEventsController", () => { + let controller: JobEventsController; + let service: JobEventsService; + + const mockJobEventsService = { + getEventsByJobId: 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: [JobEventsController], + providers: [ + { + provide: JobEventsService, + useValue: mockJobEventsService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(WorkspaceGuard) + .useValue(mockWorkspaceGuard) + .overrideGuard(PermissionGuard) + .useValue(mockPermissionGuard) + .compile(); + + controller = module.get(JobEventsController); + service = module.get(JobEventsService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getEvents", () => { + const jobId = "job-123"; + const workspaceId = "workspace-123"; + const mockEvents = { + data: [ + { + id: "event-1", + jobId, + stepId: null, + type: JOB_CREATED, + timestamp: new Date("2026-01-01T10:00:00Z"), + actor: "system", + payload: {}, + }, + ], + meta: { + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }, + }; + + it("should return paginated events for a job", async () => { + mockJobEventsService.getEventsByJobId.mockResolvedValue(mockEvents); + + const result = await controller.getEvents(jobId, {}, workspaceId); + + expect(service.getEventsByJobId).toHaveBeenCalledWith(jobId, {}); + expect(result).toEqual(mockEvents); + }); + + it("should pass query parameters to service", async () => { + const query = { type: JOB_CREATED, page: 2, limit: 10 }; + mockJobEventsService.getEventsByJobId.mockResolvedValue(mockEvents); + + await controller.getEvents(jobId, query, workspaceId); + + expect(service.getEventsByJobId).toHaveBeenCalledWith(jobId, query); + }); + + it("should handle filtering by type", async () => { + const query = { type: JOB_CREATED }; + mockJobEventsService.getEventsByJobId.mockResolvedValue(mockEvents); + + const result = await controller.getEvents(jobId, query, workspaceId); + + expect(service.getEventsByJobId).toHaveBeenCalledWith(jobId, query); + expect(result).toEqual(mockEvents); + }); + + it("should handle pagination parameters", async () => { + const query = { page: 2, limit: 25 }; + mockJobEventsService.getEventsByJobId.mockResolvedValue({ + ...mockEvents, + meta: { + total: 100, + page: 2, + limit: 25, + totalPages: 4, + }, + }); + + const result = await controller.getEvents(jobId, query, workspaceId); + + expect(service.getEventsByJobId).toHaveBeenCalledWith(jobId, query); + expect(result.meta.page).toBe(2); + expect(result.meta.limit).toBe(25); + }); + }); +}); diff --git a/apps/api/src/job-events/job-events.controller.ts b/apps/api/src/job-events/job-events.controller.ts new file mode 100644 index 0000000..3694026 --- /dev/null +++ b/apps/api/src/job-events/job-events.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { JobEventsService } from "./job-events.service"; +import { QueryEventsDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; + +/** + * Controller for job events endpoints + * Provides read-only access to job events for audit logging + * + * 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/events") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class JobEventsController { + constructor(private readonly jobEventsService: JobEventsService) {} + + /** + * GET /api/runner-jobs/:jobId/events + * Get paginated events for a specific job + * Requires: Any workspace member (including GUEST) + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async getEvents( + @Param("jobId") jobId: string, + @Query() query: QueryEventsDto, + @Workspace() _workspaceId: string + ) { + return this.jobEventsService.getEventsByJobId(jobId, query); + } +} diff --git a/apps/api/src/job-events/job-events.module.ts b/apps/api/src/job-events/job-events.module.ts new file mode 100644 index 0000000..87d9ff4 --- /dev/null +++ b/apps/api/src/job-events/job-events.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { JobEventsController } from "./job-events.controller"; +import { JobEventsService } from "./job-events.service"; +import { PrismaModule } from "../prisma/prisma.module"; + +/** + * Job Events Module + * + * Provides immutable event logging for runner jobs using event sourcing pattern. + * Events are stored in PostgreSQL and provide a complete audit trail. + */ +@Module({ + imports: [PrismaModule], + controllers: [JobEventsController], + providers: [JobEventsService], + exports: [JobEventsService], +}) +export class JobEventsModule {} diff --git a/apps/api/src/job-events/job-events.service.spec.ts b/apps/api/src/job-events/job-events.service.spec.ts new file mode 100644 index 0000000..c7ee107 --- /dev/null +++ b/apps/api/src/job-events/job-events.service.spec.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { JobEventsService } from "./job-events.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { NotFoundException } from "@nestjs/common"; +import { JOB_CREATED, STEP_STARTED, AI_TOKENS_USED } from "./event-types"; + +describe("JobEventsService", () => { + let service: JobEventsService; + let prisma: PrismaService; + + const mockPrismaService = { + runnerJob: { + findUnique: vi.fn(), + }, + jobStep: { + findUnique: vi.fn(), + }, + jobEvent: { + create: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JobEventsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(JobEventsService); + prisma = module.get(PrismaService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("emitEvent", () => { + const jobId = "job-123"; + const mockEvent = { + id: "event-123", + jobId, + stepId: null, + type: JOB_CREATED, + timestamp: new Date(), + actor: "system", + payload: { message: "Job created" }, + }; + + it("should create a job event without stepId", async () => { + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobEvent.create.mockResolvedValue(mockEvent); + + const result = await service.emitEvent(jobId, { + type: JOB_CREATED, + actor: "system", + payload: { message: "Job created" }, + }); + + expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({ + where: { id: jobId }, + select: { id: true }, + }); + expect(prisma.jobEvent.create).toHaveBeenCalledWith({ + data: { + job: { connect: { id: jobId } }, + type: JOB_CREATED, + timestamp: expect.any(Date), + actor: "system", + payload: { message: "Job created" }, + }, + }); + expect(result).toEqual(mockEvent); + }); + + it("should create a job event with stepId", async () => { + const stepId = "step-123"; + const eventWithStep = { ...mockEvent, stepId, type: STEP_STARTED }; + + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobStep.findUnique.mockResolvedValue({ id: stepId }); + mockPrismaService.jobEvent.create.mockResolvedValue(eventWithStep); + + const result = await service.emitEvent(jobId, { + type: STEP_STARTED, + actor: "system", + payload: { stepName: "Setup" }, + stepId, + }); + + expect(prisma.jobStep.findUnique).toHaveBeenCalledWith({ + where: { id: stepId }, + select: { id: true }, + }); + expect(prisma.jobEvent.create).toHaveBeenCalledWith({ + data: { + job: { connect: { id: jobId } }, + step: { connect: { id: stepId } }, + type: STEP_STARTED, + timestamp: expect.any(Date), + actor: "system", + payload: { stepName: "Setup" }, + }, + }); + expect(result).toEqual(eventWithStep); + }); + + it("should throw NotFoundException if job does not exist", async () => { + mockPrismaService.runnerJob.findUnique.mockResolvedValue(null); + + await expect( + service.emitEvent(jobId, { + type: JOB_CREATED, + actor: "system", + payload: {}, + }) + ).rejects.toThrow(NotFoundException); + }); + + it("should throw NotFoundException if step does not exist", async () => { + const stepId = "step-invalid"; + + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobStep.findUnique.mockResolvedValue(null); + + await expect( + service.emitEvent(jobId, { + type: STEP_STARTED, + actor: "system", + payload: {}, + stepId, + }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("getEventsByJobId", () => { + const jobId = "job-123"; + const mockEvents = [ + { + id: "event-1", + jobId, + stepId: null, + type: JOB_CREATED, + timestamp: new Date("2026-01-01T10:00:00Z"), + actor: "system", + payload: {}, + }, + { + id: "event-2", + jobId, + stepId: "step-1", + type: STEP_STARTED, + timestamp: new Date("2026-01-01T10:01:00Z"), + actor: "system", + payload: {}, + }, + ]; + + it("should return paginated events for a job", async () => { + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobEvent.findMany.mockResolvedValue(mockEvents); + mockPrismaService.jobEvent.count.mockResolvedValue(2); + + const result = await service.getEventsByJobId(jobId, {}); + + expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({ + where: { id: jobId }, + select: { id: true }, + }); + expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({ + where: { jobId }, + orderBy: { timestamp: "asc" }, + skip: 0, + take: 50, + }); + expect(prisma.jobEvent.count).toHaveBeenCalledWith({ + where: { jobId }, + }); + expect(result).toEqual({ + data: mockEvents, + meta: { + total: 2, + page: 1, + limit: 50, + totalPages: 1, + }, + }); + }); + + it("should filter events by type", async () => { + const filteredEvents = [mockEvents[0]]; + + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobEvent.findMany.mockResolvedValue(filteredEvents); + mockPrismaService.jobEvent.count.mockResolvedValue(1); + + const result = await service.getEventsByJobId(jobId, { type: JOB_CREATED }); + + expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({ + where: { jobId, type: JOB_CREATED }, + orderBy: { timestamp: "asc" }, + skip: 0, + take: 50, + }); + expect(result.data).toHaveLength(1); + expect(result.meta.total).toBe(1); + }); + + it("should filter events by stepId", async () => { + const stepId = "step-1"; + const filteredEvents = [mockEvents[1]]; + + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobEvent.findMany.mockResolvedValue(filteredEvents); + mockPrismaService.jobEvent.count.mockResolvedValue(1); + + const result = await service.getEventsByJobId(jobId, { stepId }); + + expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({ + where: { jobId, stepId }, + orderBy: { timestamp: "asc" }, + skip: 0, + take: 50, + }); + expect(result.data).toHaveLength(1); + }); + + it("should paginate results correctly", async () => { + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobEvent.findMany.mockResolvedValue([mockEvents[1]]); + mockPrismaService.jobEvent.count.mockResolvedValue(2); + + const result = await service.getEventsByJobId(jobId, { page: 2, limit: 1 }); + + expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({ + where: { jobId }, + orderBy: { timestamp: "asc" }, + skip: 1, + take: 1, + }); + expect(result.data).toHaveLength(1); + expect(result.meta.page).toBe(2); + expect(result.meta.limit).toBe(1); + expect(result.meta.totalPages).toBe(2); + }); + + it("should throw NotFoundException if job does not exist", async () => { + mockPrismaService.runnerJob.findUnique.mockResolvedValue(null); + + await expect(service.getEventsByJobId(jobId, {})).rejects.toThrow(NotFoundException); + }); + }); + + describe("convenience methods", () => { + const jobId = "job-123"; + + beforeEach(() => { + mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId }); + mockPrismaService.jobEvent.create.mockResolvedValue({ + id: "event-123", + jobId, + stepId: null, + type: JOB_CREATED, + timestamp: new Date(), + actor: "system", + payload: {}, + }); + }); + + it("should emit job.created event", async () => { + await service.emitJobCreated(jobId, { type: "code-task" }); + + expect(prisma.jobEvent.create).toHaveBeenCalledWith({ + data: { + job: { connect: { id: jobId } }, + type: JOB_CREATED, + timestamp: expect.any(Date), + actor: "system", + payload: { type: "code-task" }, + }, + }); + }); + + it("should emit job.started event", async () => { + await service.emitJobStarted(jobId); + + expect(prisma.jobEvent.create).toHaveBeenCalledWith({ + data: { + job: { connect: { id: jobId } }, + type: "job.started", + timestamp: expect.any(Date), + actor: "system", + payload: {}, + }, + }); + }); + + it("should emit step.started event", async () => { + const stepId = "step-123"; + mockPrismaService.jobStep.findUnique.mockResolvedValue({ id: stepId }); + + await service.emitStepStarted(jobId, stepId, { name: "Setup" }); + + expect(prisma.jobEvent.create).toHaveBeenCalledWith({ + data: { + job: { connect: { id: jobId } }, + step: { connect: { id: stepId } }, + type: STEP_STARTED, + timestamp: expect.any(Date), + actor: "system", + payload: { name: "Setup" }, + }, + }); + }); + + it("should emit ai.tokens_used event", async () => { + await service.emitAiTokensUsed(jobId, { input: 100, output: 50 }); + + expect(prisma.jobEvent.create).toHaveBeenCalledWith({ + data: { + job: { connect: { id: jobId } }, + type: AI_TOKENS_USED, + timestamp: expect.any(Date), + actor: "system", + payload: { input: 100, output: 50 }, + }, + }); + }); + }); +}); diff --git a/apps/api/src/job-events/job-events.service.ts b/apps/api/src/job-events/job-events.service.ts new file mode 100644 index 0000000..0a81e8f --- /dev/null +++ b/apps/api/src/job-events/job-events.service.ts @@ -0,0 +1,197 @@ +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 = {}) { + return this.emitEvent(jobId, { + type: JOB_CREATED, + actor: "system", + payload, + }); + } + + /** + * Convenience method: Emit job.started event + */ + async emitJobStarted(jobId: string, payload: Record = {}) { + return this.emitEvent(jobId, { + type: JOB_STARTED, + actor: "system", + payload, + }); + } + + /** + * Convenience method: Emit job.completed event + */ + async emitJobCompleted(jobId: string, payload: Record = {}) { + return this.emitEvent(jobId, { + type: JOB_COMPLETED, + actor: "system", + payload, + }); + } + + /** + * Convenience method: Emit job.failed event + */ + async emitJobFailed(jobId: string, payload: Record = {}) { + return this.emitEvent(jobId, { + type: JOB_FAILED, + actor: "system", + payload, + }); + } + + /** + * Convenience method: Emit step.started event + */ + async emitStepStarted(jobId: string, stepId: string, payload: Record = {}) { + 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 = {}) { + return this.emitEvent(jobId, { + type: STEP_COMPLETED, + actor: "system", + payload, + stepId, + }); + } + + /** + * Convenience method: Emit ai.tokens_used event + */ + async emitAiTokensUsed(jobId: string, payload: Record = {}) { + return this.emitEvent(jobId, { + type: AI_TOKENS_USED, + actor: "system", + payload, + }); + } +} diff --git a/apps/api/src/job-steps/dto/create-step.dto.ts b/apps/api/src/job-steps/dto/create-step.dto.ts new file mode 100644 index 0000000..24233be --- /dev/null +++ b/apps/api/src/job-steps/dto/create-step.dto.ts @@ -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; +} diff --git a/apps/api/src/job-steps/dto/index.ts b/apps/api/src/job-steps/dto/index.ts new file mode 100644 index 0000000..76ce472 --- /dev/null +++ b/apps/api/src/job-steps/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./create-step.dto"; +export * from "./update-step.dto"; diff --git a/apps/api/src/job-steps/dto/update-step.dto.ts b/apps/api/src/job-steps/dto/update-step.dto.ts new file mode 100644 index 0000000..391bd6b --- /dev/null +++ b/apps/api/src/job-steps/dto/update-step.dto.ts @@ -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; +} diff --git a/apps/api/src/job-steps/index.ts b/apps/api/src/job-steps/index.ts new file mode 100644 index 0000000..7bea8d0 --- /dev/null +++ b/apps/api/src/job-steps/index.ts @@ -0,0 +1,4 @@ +export * from "./job-steps.module"; +export * from "./job-steps.service"; +export * from "./job-steps.controller"; +export * from "./dto"; diff --git a/apps/api/src/job-steps/job-steps.controller.spec.ts b/apps/api/src/job-steps/job-steps.controller.spec.ts new file mode 100644 index 0000000..6da9bee --- /dev/null +++ b/apps/api/src/job-steps/job-steps.controller.spec.ts @@ -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); + service = module.get(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); + }); + }); +}); diff --git a/apps/api/src/job-steps/job-steps.controller.ts b/apps/api/src/job-steps/job-steps.controller.ts new file mode 100644 index 0000000..aa3e90c --- /dev/null +++ b/apps/api/src/job-steps/job-steps.controller.ts @@ -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); + } +} diff --git a/apps/api/src/job-steps/job-steps.module.ts b/apps/api/src/job-steps/job-steps.module.ts new file mode 100644 index 0000000..72aa478 --- /dev/null +++ b/apps/api/src/job-steps/job-steps.module.ts @@ -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 {} diff --git a/apps/api/src/job-steps/job-steps.service.spec.ts b/apps/api/src/job-steps/job-steps.service.spec.ts new file mode 100644 index 0000000..76a3a1a --- /dev/null +++ b/apps/api/src/job-steps/job-steps.service.spec.ts @@ -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); + prisma = module.get(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), + }, + }); + }); + }); +}); diff --git a/apps/api/src/job-steps/job-steps.service.ts b/apps/api/src/job-steps/job-steps.service.ts new file mode 100644 index 0000000..9007a87 --- /dev/null +++ b/apps/api/src/job-steps/job-steps.service.ts @@ -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, + }, + }); + } +} diff --git a/docs/reports/m4.2-token-tracking.md b/docs/reports/m4.2-token-tracking.md index 80e2b51..57e4ab3 100644 --- a/docs/reports/m4.2-token-tracking.md +++ b/docs/reports/m4.2-token-tracking.md @@ -61,24 +61,26 @@ ### Issue 166 - [INFRA-004] Stitcher module structure - **Estimate:** 50,000 tokens (sonnet) -- **Actual:** _pending_ -- **Variance:** _pending_ -- **Agent ID:** _pending_ -- **Status:** pending +- **Actual:** ~62,000 tokens (sonnet) +- **Variance:** +24% (over estimate) +- **Agent ID:** af3724d +- **Status:** ✅ completed - **Dependencies:** #165 -- **Notes:** Workflow orchestration wrapper for OpenClaw +- **Quality Gates:** ✅ All passed (12 tests, typecheck, lint, build) +- **Notes:** Implemented webhook endpoint, Guard Rails, Quality Rails, BullMQ integration. Service and controller with full test coverage. --- ### Issue 167 - [INFRA-005] Runner jobs CRUD and queue submission - **Estimate:** 55,000 tokens (sonnet) -- **Actual:** _pending_ -- **Variance:** _pending_ -- **Agent ID:** _pending_ -- **Status:** pending +- **Actual:** ~76,000 tokens (sonnet) +- **Variance:** +38% (over estimate) +- **Agent ID:** aa914a0 +- **Status:** ✅ completed - **Dependencies:** #164, #165 -- **Notes:** Job lifecycle management, BullMQ queue submission +- **Quality Gates:** ✅ All passed (24 tests, typecheck, lint, build) +- **Notes:** Implemented 5 REST endpoints (create, list, get, cancel, retry) with BullMQ integration and Prisma persistence. --- @@ -251,9 +253,9 @@ ### Phase 2: Stitcher Service - **Estimated:** 205,000 tokens -- **Actual:** _pending_ +- **Actual:** _in_progress_ (~138,000 for #166, #167) - **Variance:** _pending_ -- **Issues:** #166, #167, #168, #169 +- **Issues:** #166 (✅), #167 (✅), #168, #169 ### Phase 3: Chat Integration @@ -333,6 +335,10 @@ _Execution events will be logged here as work progresses._ [2026-02-01 19:18] Issue #164 COMPLETED - Agent a1585e8 - ~65,000 tokens [2026-02-01 19:18] Wave 1 COMPLETE - Total: ~145,000 tokens [2026-02-01 19:18] Wave 2 STARTED - Stitcher core (#166, #167) +[2026-02-01 19:25] Issue #166 COMPLETED - Agent af3724d - ~62,000 tokens +[2026-02-01 19:32] Issue #167 COMPLETED - Agent aa914a0 - ~76,000 tokens +[2026-02-01 19:32] Wave 2 COMPLETE - Total: ~138,000 tokens +[2026-02-01 19:32] Wave 3 STARTED - Stitcher events (#168, #169) ``` ## Notes diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..1f625d3 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/app.module.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:10 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_2_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_2_remediation_needed.md new file mode 100644 index 0000000..cf0ebd0 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_2_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/app.module.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 2 +**Generated:** 2026-02-01 21:13:32 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_2_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_3_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_3_remediation_needed.md new file mode 100644 index 0000000..4ca8e6d --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_3_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/app.module.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 3 +**Generated:** 2026-02-01 21:13:34 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-app.module.ts_20260201-2113_3_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..6d61eeb --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/dto/create-event.dto.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:30 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2114_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2114_1_remediation_needed.md new file mode 100644 index 0000000..745d880 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2114_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/dto/create-event.dto.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:14:22 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-create-event.dto.ts_20260201-2114_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-index.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-index.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..8fc7b11 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-index.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/dto/index.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:40 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-index.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-query-events.dto.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-query-events.dto.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..c892f19 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-query-events.dto.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/dto/query-events.dto.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:37 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-dto-query-events.dto.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-event-types.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-event-types.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..cbbf0e0 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-event-types.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/event-types.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:26 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-event-types.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-index.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-index.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..289fa9c --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-index.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/index.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:01 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-index.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..f6db264 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.controller.spec.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:44 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..b9535cd --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.controller.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:40 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_2_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_2_remediation_needed.md new file mode 100644 index 0000000..7803077 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_2_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.controller.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 2 +**Generated:** 2026-02-01 21:13:43 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2113_2_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2114_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2114_1_remediation_needed.md new file mode 100644 index 0000000..bbceffc --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2114_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.controller.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:14:04 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.spec.ts_20260201-2114_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..3f238e7 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.controller.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:52 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.controller.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.module.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.module.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..2c2a2a1 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.module.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.module.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:56 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.module.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..4030e70 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.service.spec.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:14 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..b5b32eb --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.service.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:32 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_2_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_2_remediation_needed.md new file mode 100644 index 0000000..4ff8b02 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_2_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.service.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 2 +**Generated:** 2026-02-01 21:13:35 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.spec.ts_20260201-2113_2_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..ac61505 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-events/job-events.service.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:32 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-events-job-events.service.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-create-step.dto.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-create-step.dto.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..283625c --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-create-step.dto.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/dto/create-step.dto.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:19 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-create-step.dto.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-index.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-index.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..4e1c2ea --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-index.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/dto/index.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:23 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-index.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-update-step.dto.ts_20260201-2111_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-update-step.dto.ts_20260201-2111_1_remediation_needed.md new file mode 100644 index 0000000..b244b56 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-update-step.dto.ts_20260201-2111_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/dto/update-step.dto.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:11:22 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-dto-update-step.dto.ts_20260201-2111_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-index.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-index.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..ee33796 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-index.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/index.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:25 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-index.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..dd122f0 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.controller.spec.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:43 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..0d0e39a --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.controller.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:14 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.spec.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..0ee64b0 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.controller.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:52 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.controller.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.module.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.module.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..3bde49e --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.module.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.module.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:24 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.module.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.spec.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.spec.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..a0e6437 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.spec.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.service.spec.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:08 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.spec.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2112_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2112_1_remediation_needed.md new file mode 100644 index 0000000..0c3b656 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2112_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.service.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:12:23 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2112_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2113_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2113_1_remediation_needed.md new file mode 100644 index 0000000..b79e197 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2113_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.service.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-01 21:13:49 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-job-steps-job-steps.service.ts_20260201-2113_1_remediation_needed.md" +``` diff --git a/docs/scratchpads/167-runner-jobs-crud.md b/docs/scratchpads/167-runner-jobs-crud.md index 6105ef5..5e4adb6 100644 --- a/docs/scratchpads/167-runner-jobs-crud.md +++ b/docs/scratchpads/167-runner-jobs-crud.md @@ -54,10 +54,40 @@ Implement runner-jobs module for job lifecycle management and queue submission, - Mock Prisma for database operations - Target: ≥85% coverage +## Implementation Summary + +**Files Created:** + +- apps/api/src/runner-jobs/dto/create-job.dto.ts - Job creation DTO +- apps/api/src/runner-jobs/dto/query-jobs.dto.ts - Job query DTO +- apps/api/src/runner-jobs/dto/index.ts - DTO barrel export +- apps/api/src/runner-jobs/runner-jobs.service.ts - Service implementation +- apps/api/src/runner-jobs/runner-jobs.service.spec.ts - Service tests (18 tests) +- apps/api/src/runner-jobs/runner-jobs.controller.ts - Controller implementation +- apps/api/src/runner-jobs/runner-jobs.controller.spec.ts - Controller tests (6 tests) +- apps/api/src/runner-jobs/runner-jobs.module.ts - Module configuration +- apps/api/src/runner-jobs/index.ts - Module barrel export + +**Key Implementation Details:** + +1. Used Prisma relations (workspace.connect, agentTask.connect) for foreign keys +2. Optional fields only included when present (result, agentTaskId) +3. BullMQ integration for async job processing via QUEUE_NAMES.RUNNER +4. Comprehensive error handling (NotFoundException, BadRequestException) +5. Following existing patterns from tasks/events modules + +**Test Coverage:** + +- Service: 18 tests covering create, findAll, findOne, cancel, retry +- Controller: 6 tests covering all endpoints +- Total: 24 tests, all passing + +**Token Usage Estimate:** ~76,000 tokens + ## Notes -- Follow existing CRUD patterns from tasks/events modules -- Use DTOs for validation -- Integrate with BullMqService for queue submission -- Use Prisma for all database operations -- Follow PDA-friendly language principles in responses +- Followed existing CRUD patterns from tasks/events modules +- Used DTOs for validation +- Integrated with BullMqService for queue submission +- Used Prisma for all database operations +- Followed PDA-friendly language principles in responses diff --git a/docs/scratchpads/168-job-steps-tracking.md b/docs/scratchpads/168-job-steps-tracking.md new file mode 100644 index 0000000..82c56ac --- /dev/null +++ b/docs/scratchpads/168-job-steps-tracking.md @@ -0,0 +1,66 @@ +# Issue #168: Job steps tracking + +## Objective + +Implement job-steps module for granular step tracking within jobs. This module will track individual steps (SETUP, EXECUTION, VALIDATION, CLEANUP) within a runner job, recording status transitions, token usage, and duration. + +## Approach + +1. Analyze existing RunnerJobsModule and JobStep model +2. Create JobStepsModule with TDD approach +3. Implement service layer for step CRUD and status tracking +4. Implement controller with GET endpoints +5. Ensure proper integration with RunnerJobsModule + +## Progress + +- [x] Analyze existing code structure +- [x] Create directory structure and DTOs +- [x] RED: Write tests for JobStepsService +- [x] GREEN: Implement JobStepsService +- [x] RED: Write tests for JobStepsController +- [x] GREEN: Implement JobStepsController +- [x] Create JobStepsModule +- [x] REFACTOR: Clean up and optimize +- [x] Quality gates: typecheck, lint, test, coverage +- [x] Commit changes + +## Testing + +- Unit tests for service methods (13 tests) +- Unit tests for controller endpoints (3 tests) +- Mock Prisma service +- Verify token usage tracking +- Verify duration calculation +- Coverage: 100% statements, 100% functions, 100% lines, 83.33% branches + +## Notes + +- Step types: COMMAND, AI_ACTION, GATE, ARTIFACT +- Step phases: SETUP, EXECUTION, VALIDATION, CLEANUP +- Status transitions: pending → running → completed/failed +- Track token usage per step (for AI_ACTION steps) +- Calculate duration on completion + +## Implementation Summary + +Created the following files: + +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.module.ts` - Module definition +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.service.ts` - Service with CRUD operations +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.controller.ts` - Controller with GET endpoints +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/dto/create-step.dto.ts` - DTO for creating steps +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/dto/update-step.dto.ts` - DTO for updating steps +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/dto/index.ts` - DTO exports +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.service.spec.ts` - Service tests (13 tests) +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/job-steps.controller.spec.ts` - Controller tests (3 tests) +- `/home/jwoltje/src/mosaic-stack/apps/api/src/job-steps/index.ts` - Module exports + +Also fixed pre-existing issue in job-events DTO (added `!` to required properties). + +## Quality Gates + +- ✅ Build: Passed +- ✅ Lint: Passed (auto-fixed formatting) +- ✅ Tests: 16/16 passed +- ✅ Coverage: 100% statements, 100% functions, 100% lines, 83.33% branches diff --git a/docs/scratchpads/169-job-events-audit.md b/docs/scratchpads/169-job-events-audit.md new file mode 100644 index 0000000..ee75ff9 --- /dev/null +++ b/docs/scratchpads/169-job-events-audit.md @@ -0,0 +1,109 @@ +# Issue #169: Job events and audit logging + +## Objective + +Implement job-events module for immutable audit logging using event sourcing pattern. + +## Approach + +1. Create module structure (module, service, controller, DTOs) +2. Define event type constants +3. Implement event emission and persistence (PostgreSQL) +4. Add API endpoints for querying events +5. Follow TDD: Write tests first, then implementation + +## Event Types + +- Job lifecycle: job.created, job.queued, job.started, job.completed, job.failed +- Step lifecycle: step.started, step.progress, step.output, step.completed +- AI events: ai.tool_called, ai.tokens_used, ai.artifact_created +- Gate events: gate.started, gate.passed, gate.failed + +## Storage Strategy + +- PostgreSQL: Immutable audit log (permanent) +- Valkey Streams: Deferred to future issue +- Valkey Pub/Sub: Deferred to future issue + +## API Endpoints + +- GET /runner-jobs/:jobId/events - List events for a job +- GET /runner-jobs/:jobId/events/stream - SSE stream (Phase 4, deferred) + +## Progress + +- [x] Create scratchpad +- [x] Review existing schema (JobEvent model) +- [x] Define event type constants +- [x] Write tests for JobEventsService +- [x] Implement JobEventsService +- [x] Write tests for JobEventsController +- [x] Implement JobEventsController +- [x] Create JobEventsModule +- [x] Register modules in app.module.ts +- [x] Run quality gates (typecheck, lint, build, test) +- [x] Commit changes + +## Testing + +- Unit tests for service (event emission, persistence, querying) +- Unit tests for controller (endpoint behavior) +- Target: >85% coverage + +Results: + +- JobEventsService: 13 tests passed +- JobEventsController: 4 tests passed +- Total: 17 tests passed +- All quality gates passed (typecheck, lint, build, test) + +## Notes + +- Events are immutable once created +- JobEvent model already exists in Prisma schema (from #164) +- RunnerJobsModule available (from #167) +- SSE streaming deferred to Phase 4 + +## Implementation Details + +Files Created: + +- /apps/api/src/job-events/event-types.ts - Event type constants +- /apps/api/src/job-events/dto/create-event.dto.ts - DTO for creating events +- /apps/api/src/job-events/dto/query-events.dto.ts - DTO for querying events +- /apps/api/src/job-events/dto/index.ts - DTO exports +- /apps/api/src/job-events/job-events.service.ts - Event service implementation +- /apps/api/src/job-events/job-events.service.spec.ts - Service tests (13 tests) +- /apps/api/src/job-events/job-events.controller.ts - Event controller +- /apps/api/src/job-events/job-events.controller.spec.ts - Controller tests (4 tests) +- /apps/api/src/job-events/job-events.module.ts - Module definition +- /apps/api/src/job-events/index.ts - Module exports + +Files Modified: + +- /apps/api/src/app.module.ts - Registered JobEventsModule + +Event Types Implemented: + +- Job lifecycle: job.created, job.queued, job.started, job.completed, job.failed, job.cancelled +- Step lifecycle: step.started, step.progress, step.output, step.completed, step.failed +- AI events: ai.tool_called, ai.tokens_used, ai.artifact_created +- Gate events: gate.started, gate.passed, gate.failed + +API Endpoints: + +- GET /api/runner-jobs/:jobId/events - List events for a job (with pagination and filtering) + +Service Methods: + +- emitEvent() - Generic event emission +- getEventsByJobId() - Query events with filters +- Convenience methods: emitJobCreated(), emitJobStarted(), emitStepStarted(), emitAiTokensUsed(), etc. + +Quality Gates: + +- Typecheck: PASSED +- Lint: PASSED +- Build: PASSED +- Tests: PASSED (17/17 tests) +- Full test suite: PASSED (1327 tests)