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 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:42:44 -06:00
parent 8f3949e388
commit d3058cb3de
6 changed files with 1034 additions and 0 deletions

View File

@@ -145,4 +145,87 @@ export class JobStepsService {
},
});
}
/**
* 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);
}
}