feat(#382): Herald Service: broadcast to all active chat providers
Some checks failed
ci/woodpecker/push/api Pipeline failed
Some checks failed
ci/woodpecker/push/api Pipeline failed
- Replace direct DiscordService injection with CHAT_PROVIDERS array - Herald broadcasts to ALL active chat providers (Discord, Matrix, future) - Graceful error handling — one provider failure doesn't block others - Skips disconnected providers automatically - Tests verify multi-provider broadcasting behavior - Fix lint: remove unnecessary conditional in matrix.service.ts Refs #382
This commit is contained in:
@@ -10,7 +10,7 @@ import { BridgeModule } from "../bridge/bridge.module";
|
||||
* - Subscribe to job events
|
||||
* - Format status messages with PDA-friendly language
|
||||
* - Route to appropriate channels based on workspace config
|
||||
* - Support Discord (via bridge) and PR comments
|
||||
* - Broadcast to ALL active chat providers via CHAT_PROVIDERS token
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule, BridgeModule],
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { HeraldService } from "./herald.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DiscordService } from "../bridge/discord/discord.service";
|
||||
import { CHAT_PROVIDERS } from "../bridge/bridge.constants";
|
||||
import type { IChatProvider } from "../bridge/interfaces/chat-provider.interface";
|
||||
import {
|
||||
JOB_CREATED,
|
||||
JOB_STARTED,
|
||||
@@ -14,10 +15,31 @@ import {
|
||||
GATE_FAILED,
|
||||
} from "../job-events/event-types";
|
||||
|
||||
function createMockProvider(
|
||||
name: string,
|
||||
connected = true
|
||||
): IChatProvider & {
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
sendThreadMessage: ReturnType<typeof vi.fn>;
|
||||
createThread: ReturnType<typeof vi.fn>;
|
||||
isConnected: ReturnType<typeof vi.fn>;
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
parseCommand: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
isConnected: vi.fn().mockReturnValue(connected),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
createThread: vi.fn().mockResolvedValue("thread-id"),
|
||||
sendThreadMessage: vi.fn().mockResolvedValue(undefined),
|
||||
parseCommand: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
describe("HeraldService", () => {
|
||||
let service: HeraldService;
|
||||
let prisma: PrismaService;
|
||||
let discord: DiscordService;
|
||||
|
||||
const mockPrisma = {
|
||||
workspace: {
|
||||
@@ -31,14 +53,15 @@ describe("HeraldService", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const mockDiscord = {
|
||||
isConnected: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
sendThreadMessage: vi.fn(),
|
||||
createThread: vi.fn(),
|
||||
};
|
||||
let mockProviderA: ReturnType<typeof createMockProvider>;
|
||||
let mockProviderB: ReturnType<typeof createMockProvider>;
|
||||
let chatProviders: IChatProvider[];
|
||||
|
||||
beforeEach(async () => {
|
||||
mockProviderA = createMockProvider("providerA", true);
|
||||
mockProviderB = createMockProvider("providerB", true);
|
||||
chatProviders = [mockProviderA, mockProviderB];
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HeraldService,
|
||||
@@ -47,44 +70,28 @@ describe("HeraldService", () => {
|
||||
useValue: mockPrisma,
|
||||
},
|
||||
{
|
||||
provide: DiscordService,
|
||||
useValue: mockDiscord,
|
||||
provide: CHAT_PROVIDERS,
|
||||
useValue: chatProviders,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HeraldService>(HeraldService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
discord = module.get<DiscordService>(DiscordService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
// Restore default connected state after clearAllMocks
|
||||
mockProviderA.isConnected.mockReturnValue(true);
|
||||
mockProviderB.isConnected.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("broadcastJobEvent", () => {
|
||||
it("should broadcast job.created event to configured channel", async () => {
|
||||
// Arrange
|
||||
const baseSetup = (): {
|
||||
jobId: string;
|
||||
workspaceId: string;
|
||||
} => {
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: { issueNumber: 42 },
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: {
|
||||
herald: {
|
||||
channelMappings: {
|
||||
"code-task": "channel-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
@@ -98,23 +105,38 @@ describe("HeraldService", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
return { jobId, workspaceId };
|
||||
};
|
||||
|
||||
it("should broadcast to all connected providers", async () => {
|
||||
// Arrange
|
||||
const { jobId } = baseSetup();
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: { issueNumber: 42 },
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("Job created"),
|
||||
});
|
||||
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("Job created"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should broadcast job.started event", async () => {
|
||||
it("should broadcast job.started event to all providers", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const { jobId } = baseSetup();
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
@@ -124,31 +146,15 @@ describe("HeraldService", () => {
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("Job started"),
|
||||
});
|
||||
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("Job started"),
|
||||
});
|
||||
@@ -156,8 +162,7 @@ describe("HeraldService", () => {
|
||||
|
||||
it("should broadcast job.completed event with success message", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const { jobId } = baseSetup();
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
@@ -167,31 +172,11 @@ describe("HeraldService", () => {
|
||||
payload: { duration: 120 },
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("completed"),
|
||||
});
|
||||
@@ -199,8 +184,7 @@ describe("HeraldService", () => {
|
||||
|
||||
it("should broadcast job.failed event with PDA-friendly language", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const { jobId } = baseSetup();
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
@@ -210,43 +194,28 @@ describe("HeraldService", () => {
|
||||
payload: { error: "Build failed" },
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("encountered an issue"),
|
||||
});
|
||||
// Verify the actual message doesn't contain demanding language
|
||||
const actualCall = mockDiscord.sendThreadMessage.mock.calls[0][0];
|
||||
const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as {
|
||||
threadId: string;
|
||||
content: string;
|
||||
};
|
||||
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
|
||||
});
|
||||
|
||||
it("should skip broadcasting if Discord is not connected", async () => {
|
||||
it("should skip disconnected providers", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const { jobId } = baseSetup();
|
||||
mockProviderA.isConnected.mockReturnValue(true);
|
||||
mockProviderB.isConnected.mockReturnValue(false);
|
||||
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
@@ -256,14 +225,36 @@ describe("HeraldService", () => {
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockProviderB.sendThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle empty providers array without crashing", async () => {
|
||||
// Arrange — rebuild module with empty providers
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HeraldService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrisma,
|
||||
},
|
||||
{
|
||||
provide: CHAT_PROVIDERS,
|
||||
useValue: [],
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const emptyService = module.get<HeraldService>(HeraldService);
|
||||
|
||||
const jobId = "job-1";
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
workspaceId: "workspace-1",
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
@@ -273,19 +264,6 @@ describe("HeraldService", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(false);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should skip broadcasting if job has no threadId", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
@@ -295,14 +273,59 @@ describe("HeraldService", () => {
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
// Act & Assert — should not throw
|
||||
await expect(emptyService.broadcastJobEvent(jobId, event)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should continue broadcasting when one provider errors", async () => {
|
||||
// Arrange
|
||||
const { jobId } = baseSetup();
|
||||
mockProviderA.sendThreadMessage.mockRejectedValue(new Error("Provider A rate limit"));
|
||||
mockProviderB.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
// Act — should not throw despite provider A failing
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert — provider B should still have been called
|
||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not throw when all providers error", async () => {
|
||||
// Arrange
|
||||
const { jobId } = baseSetup();
|
||||
mockProviderA.sendThreadMessage.mockRejectedValue(new Error("Provider A down"));
|
||||
mockProviderB.sendThreadMessage.mockRejectedValue(new Error("Provider B down"));
|
||||
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
// Act & Assert — should not throw; provider errors are logged, not propagated
|
||||
await expect(service.broadcastJobEvent(jobId, event)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should skip broadcasting if job has no threadId", async () => {
|
||||
// Arrange
|
||||
const jobId = "job-1";
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
workspaceId: "workspace-1",
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
@@ -312,16 +335,45 @@ describe("HeraldService", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
|
||||
expect(mockProviderA.sendThreadMessage).not.toHaveBeenCalled();
|
||||
expect(mockProviderB.sendThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ERROR HANDLING TESTS - Issue #185
|
||||
it("should skip broadcasting if job not found", async () => {
|
||||
// Arrange
|
||||
const jobId = "nonexistent-job";
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue(null);
|
||||
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockProviderA.sendThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ERROR HANDLING TESTS - database errors should still propagate
|
||||
|
||||
it("should propagate database errors when job lookup fails", async () => {
|
||||
// Arrange
|
||||
@@ -344,43 +396,8 @@ describe("HeraldService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate Discord send failures with context", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
|
||||
const discordError = new Error("Rate limit exceeded");
|
||||
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Rate limit exceeded");
|
||||
});
|
||||
|
||||
it("should propagate errors when fetching job events fails", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
@@ -393,61 +410,16 @@ describe("HeraldService", () => {
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
workspaceId: "workspace-1",
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
const dbError = new Error("Query timeout");
|
||||
mockPrisma.jobEvent.findFirst.mockRejectedValue(dbError);
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Query timeout");
|
||||
});
|
||||
|
||||
it("should include job context in error messages", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "test-job-123";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_COMPLETED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
|
||||
const discordError = new Error("Network failure");
|
||||
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
|
||||
|
||||
// Act & Assert
|
||||
try {
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (error) {
|
||||
// Verify error was thrown
|
||||
expect(error).toBeDefined();
|
||||
// Verify original error is preserved
|
||||
expect((error as Error).message).toContain("Network failure");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJobEventMessage", () => {
|
||||
@@ -473,7 +445,6 @@ describe("HeraldService", () => {
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toContain("🟢");
|
||||
expect(message).toContain("Job created");
|
||||
expect(message).toContain("#42");
|
||||
expect(message.length).toBeLessThan(200); // Keep it scannable
|
||||
@@ -526,7 +497,6 @@ describe("HeraldService", () => {
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toMatch(/✅|🟢/);
|
||||
expect(message).toContain("completed");
|
||||
expect(message).not.toMatch(/COMPLETED|SUCCESS/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DiscordService } from "../bridge/discord/discord.service";
|
||||
import { CHAT_PROVIDERS } from "../bridge/bridge.constants";
|
||||
import type { IChatProvider } from "../bridge/interfaces/chat-provider.interface";
|
||||
import {
|
||||
JOB_CREATED,
|
||||
JOB_STARTED,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
* - Subscribe to job events
|
||||
* - Format status messages with PDA-friendly language
|
||||
* - Route to appropriate channels based on workspace config
|
||||
* - Support Discord (via bridge) and PR comments
|
||||
* - Broadcast to ALL active chat providers (Discord, Matrix, etc.)
|
||||
*/
|
||||
@Injectable()
|
||||
export class HeraldService {
|
||||
@@ -29,11 +30,11 @@ export class HeraldService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly discord: DiscordService
|
||||
@Inject(CHAT_PROVIDERS) private readonly chatProviders: IChatProvider[]
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Broadcast a job event to the appropriate channel
|
||||
* Broadcast a job event to all connected chat providers
|
||||
*/
|
||||
async broadcastJobEvent(
|
||||
jobId: string,
|
||||
@@ -47,66 +48,65 @@ export class HeraldService {
|
||||
payload: unknown;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get job details
|
||||
const job = await this.prisma.runnerJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Check if Discord is connected
|
||||
if (!this.discord.isConnected()) {
|
||||
this.logger.debug("Discord not connected, 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;
|
||||
|
||||
if (!threadId) {
|
||||
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format message
|
||||
const message = this.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Send to thread
|
||||
await this.discord.sendThreadMessage({
|
||||
threadId,
|
||||
content: message,
|
||||
});
|
||||
|
||||
this.logger.debug(`Broadcasted event ${event.type} for job ${jobId} to thread ${threadId}`);
|
||||
} catch (error) {
|
||||
// Log the error with full context for debugging
|
||||
this.logger.error(`Failed to broadcast event ${event.type} for job ${jobId}:`, error);
|
||||
|
||||
// Re-throw the error so callers can handle it appropriately
|
||||
// This enables proper error tracking, retry logic, and alerting
|
||||
throw error;
|
||||
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;
|
||||
|
||||
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,
|
||||
content: message,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log and continue — one provider failure must not block others
|
||||
this.logger.error(
|
||||
`Failed to broadcast event ${event.type} for job ${jobId} via provider:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(`Broadcasted event ${event.type} for job ${jobId} to thread ${threadId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user