Files
stack/apps/api/src/job-steps/job-steps.service.ts
T
jason.woltjeandClaude Opus 4.5 d3058cb3de feat(#172): Implement Herald status updates
Implements status broadcasting via bridge module to chat channels. The Herald
service subscribes to job events and broadcasts status updates to Discord threads
using PDA-friendly language.

Features:
- Herald module with HeraldService for status broadcasting
- Subscribe to job lifecycle, step lifecycle, and gate events
- Format messages with PDA-friendly language (no "FAILED", "URGENT", etc.)
- Visual indicators for quick scanning (🟢, 🔵, , ⚠️, ⏸️)
- Channel selection logic via workspace settings
- Route to Discord threads based on job metadata
- Comprehensive unit tests (14 tests passing, 85%+ coverage)

Message format examples:
- Job created: 🟢 Job created for #42
- Job started: 🔵 Job started for #42
- Job completed:  Job completed for #42 (120s)
- Job failed: ⚠️ Job encountered an issue for #42
- Gate passed:  Gate passed: build
- Gate failed: ⚠️ Gate needs attention: test

Quality gates:  typecheck, lint, test, build

PR comment support deferred - requires GitHub/Gitea API client implementation.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-01 21:42:44 -06:00

232 lines
5.8 KiB
TypeScript

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,
},
});
}
/**
* Start a step - simplified API without jobId
*/
async start(id: string): Promise<Awaited<ReturnType<typeof this.prisma.jobStep.update>>> {
const step = await this.prisma.jobStep.findUnique({
where: { id },
});
if (!step) {
throw new NotFoundException(`JobStep with ID ${id} not found`);
}
return this.startStep(id, step.jobId);
}
/**
* Complete a step - simplified API without jobId
*/
async complete(
id: string,
data?: { output?: string; tokensInput?: number; tokensOutput?: number }
): Promise<Awaited<ReturnType<typeof this.prisma.jobStep.update>>> {
const step = await this.prisma.jobStep.findUnique({
where: { id },
});
if (!step) {
throw new NotFoundException(`JobStep with ID ${id} not found`);
}
const existingStep = await this.findOne(id, step.jobId);
const completedAt = new Date();
const durationMs = existingStep.startedAt
? completedAt.getTime() - existingStep.startedAt.getTime()
: null;
const updateData: Prisma.JobStepUpdateInput = {
status: JobStepStatus.COMPLETED,
completedAt,
durationMs,
};
if (data?.output !== undefined) {
updateData.output = data.output;
}
if (data?.tokensInput !== undefined) {
updateData.tokensInput = data.tokensInput;
}
if (data?.tokensOutput !== undefined) {
updateData.tokensOutput = data.tokensOutput;
}
return this.prisma.jobStep.update({
where: { id, jobId: step.jobId },
data: updateData,
});
}
/**
* Fail a step - simplified API without jobId
*/
async fail(
id: string,
data?: { error?: string }
): Promise<Awaited<ReturnType<typeof this.prisma.jobStep.update>>> {
const step = await this.prisma.jobStep.findUnique({
where: { id },
});
if (!step) {
throw new NotFoundException(`JobStep with ID ${id} not found`);
}
return this.failStep(id, step.jobId, data?.error ?? "Step failed");
}
/**
* Get steps by job - alias for findAllByJob
*/
async findByJob(jobId: string): Promise<Awaited<ReturnType<typeof this.findAllByJob>>> {
return this.findAllByJob(jobId);
}
}