Files
stack/apps/api/src/herald/herald.service.ts
Jason Woltje 8d19ac1f4b
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/api Pipeline failed
fix(#377): remediate code review and security findings
- Fix sendThreadMessage room mismatch: use channelId from options instead of hardcoded controlRoomId
- Add .catch() to fire-and-forget handleRoomMessage to prevent silent error swallowing
- Wrap dispatchJob in try-catch for user-visible error reporting in handleFixCommand
- Add MATRIX_BOT_USER_ID validation in connect() to prevent infinite message loops
- Fix streamResponse error masking: wrap finally/catch side-effects in try-catch
- Replace unsafe type assertion with public getClient() in MatrixRoomService
- Add orphaned room warning in provisionRoom on DB failure
- Add provider identity to Herald error logs
- Add channelId to ThreadMessageOptions interface and all callers
- Add missing env var warnings in BridgeModule factory
- Fix JSON injection in setup-bot.sh: use jq for safe JSON construction

Fixes #377

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 03:00:53 -06:00

294 lines
8.4 KiB
TypeScript

import { Inject, Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CHAT_PROVIDERS } from "../bridge/bridge.constants";
import type { IChatProvider } from "../bridge/interfaces/chat-provider.interface";
import {
JOB_CREATED,
JOB_STARTED,
JOB_COMPLETED,
JOB_FAILED,
JOB_CANCELLED,
STEP_STARTED,
STEP_COMPLETED,
STEP_FAILED,
GATE_PASSED,
GATE_FAILED,
} from "../job-events/event-types";
/**
* Herald Service - Status broadcasting and notifications
*
* Responsibilities:
* - Subscribe to job events
* - Format status messages with PDA-friendly language
* - Route to appropriate channels based on workspace config
* - Broadcast to ALL active chat providers (Discord, Matrix, etc.)
*/
@Injectable()
export class HeraldService {
private readonly logger = new Logger(HeraldService.name);
constructor(
private readonly prisma: PrismaService,
@Inject(CHAT_PROVIDERS) private readonly chatProviders: IChatProvider[]
) {}
/**
* Broadcast a job event to all connected chat providers
*/
async broadcastJobEvent(
jobId: string,
event: {
id: string;
jobId: string;
stepId?: string | null;
type: string;
timestamp: Date;
actor: string;
payload: unknown;
}
): Promise<void> {
// Get job details
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: {
id: true,
workspaceId: true,
type: true,
},
});
if (!job) {
this.logger.warn(`Job ${jobId} not found, skipping broadcast`);
return;
}
// Get threadId from first event payload (job.created event has metadata)
const firstEvent = await this.prisma.jobEvent.findFirst({
where: {
jobId,
type: JOB_CREATED,
},
select: {
payload: true,
},
});
const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined;
const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined;
const threadId = metadata?.threadId as string | undefined;
const channelId = metadata?.channelId as string | undefined;
if (!threadId) {
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
return;
}
// Format message
const message = this.formatJobEventMessage(event, job, metadata);
// Broadcast to all connected providers
for (const provider of this.chatProviders) {
if (!provider.isConnected()) {
continue;
}
try {
await provider.sendThreadMessage({
threadId,
channelId: channelId ?? "",
content: message,
});
} catch (error: unknown) {
// Log and continue — one provider failure must not block others
const providerName = provider.constructor.name;
this.logger.error(
`Failed to broadcast event ${event.type} for job ${jobId} via ${providerName}:`,
error instanceof Error ? error.message : error
);
}
}
this.logger.debug(`Broadcasted event ${event.type} for job ${jobId} to thread ${threadId}`);
}
/**
* Format a job event into a PDA-friendly message
*/
formatJobEventMessage(
event: {
id: string;
jobId: string;
stepId?: string | null;
type: string;
timestamp: Date;
actor: string;
payload: unknown;
},
_job: {
id: string;
type: string;
},
metadata?: Record<string, unknown>
): string {
const payload = event.payload as Record<string, unknown>;
const issueNumber = metadata?.issueNumber as number | undefined;
switch (event.type) {
case JOB_CREATED:
return this.formatJobCreated(issueNumber, payload);
case JOB_STARTED:
return this.formatJobStarted(issueNumber, payload);
case JOB_COMPLETED:
return this.formatJobCompleted(issueNumber, payload);
case JOB_FAILED:
return this.formatJobFailed(issueNumber, payload);
case JOB_CANCELLED:
return this.formatJobCancelled(issueNumber, payload);
case STEP_STARTED:
return this.formatStepStarted(issueNumber, payload);
case STEP_COMPLETED:
return this.formatStepCompleted(issueNumber, payload);
case STEP_FAILED:
return this.formatStepFailed(issueNumber, payload);
case GATE_PASSED:
return this.formatGatePassed(issueNumber, payload);
case GATE_FAILED:
return this.formatGateFailed(issueNumber, payload);
default:
return `Event: ${event.type}`;
}
}
/**
* Get the channel ID for a job type from workspace settings
*/
async getChannelForJobType(workspaceId: string, jobType: string): Promise<string | null> {
const workspace = await this.prisma.workspace.findUnique({
where: { id: workspaceId },
select: { settings: true },
});
if (!workspace) {
return null;
}
const settings = workspace.settings as Record<string, unknown>;
const heraldSettings = settings.herald as Record<string, unknown> | undefined;
const channelMappings = heraldSettings?.channelMappings as Record<string, string> | undefined;
const defaultChannel = heraldSettings?.defaultChannel as string | undefined;
// Try to get channel for job type
if (channelMappings?.[jobType]) {
return channelMappings[jobType];
}
// Fall back to default channel
if (defaultChannel) {
return defaultChannel;
}
return null;
}
// Message formatting methods with PDA-friendly language
private formatJobCreated(
issueNumber: number | undefined,
_payload: Record<string, unknown>
): string {
const issue = issueNumber ? `#${String(issueNumber)}` : "task";
return `🟢 Job created for ${issue}`;
}
private formatJobStarted(
issueNumber: number | undefined,
_payload: Record<string, unknown>
): string {
const issue = issueNumber ? `#${String(issueNumber)}` : "task";
return `🔵 Job started for ${issue}`;
}
private formatJobCompleted(
issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const issue = issueNumber ? `#${String(issueNumber)}` : "task";
const duration = payload.duration as number | undefined;
const durationText = duration ? ` (${String(duration)}s)` : "";
return `✅ Job completed for ${issue}${durationText}`;
}
private formatJobFailed(
issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const issue = issueNumber ? `#${String(issueNumber)}` : "task";
const error = payload.error as string | undefined;
const errorText = error ? `\n${error}` : "";
return `⚠️ Job encountered an issue for ${issue}${errorText}`;
}
private formatJobCancelled(
issueNumber: number | undefined,
_payload: Record<string, unknown>
): string {
const issue = issueNumber ? `#${String(issueNumber)}` : "task";
return `⏸️ Job paused for ${issue}`;
}
private formatStepStarted(
_issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const stepName = payload.stepName as string | undefined;
return `▶️ Step started: ${stepName ?? "unknown"}`;
}
private formatStepCompleted(
_issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const stepName = payload.stepName as string | undefined;
return `✅ Step completed: ${stepName ?? "unknown"}`;
}
private formatStepFailed(
_issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const stepName = payload.stepName as string | undefined;
const error = payload.error as string | undefined;
const errorText = error ? `\n${error}` : "";
return `⚠️ Step needs attention: ${stepName ?? "unknown"}${errorText}`;
}
private formatGatePassed(
_issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const gateName = payload.gateName as string | undefined;
return `✅ Gate passed: ${gateName ?? "unknown"}`;
}
private formatGateFailed(
_issueNumber: number | undefined,
payload: Record<string, unknown>
): string {
const gateName = payload.gateName as string | undefined;
const error = payload.error as string | undefined;
const errorText = error ? `\n${error}` : "";
return `⚠️ Gate needs attention: ${gateName ?? "unknown"}${errorText}`;
}
}