diff --git a/apps/api/src/herald/herald.module.ts b/apps/api/src/herald/herald.module.ts new file mode 100644 index 0000000..cc46e89 --- /dev/null +++ b/apps/api/src/herald/herald.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { HeraldService } from "./herald.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { BridgeModule } from "../bridge/bridge.module"; + +/** + * Herald Module - Status broadcasting and notifications + * + * Responsibilities: + * - 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 + */ +@Module({ + imports: [PrismaModule, BridgeModule], + providers: [HeraldService], + exports: [HeraldService], +}) +export class HeraldModule {} diff --git a/apps/api/src/herald/herald.service.spec.ts b/apps/api/src/herald/herald.service.spec.ts new file mode 100644 index 0000000..f848ba0 --- /dev/null +++ b/apps/api/src/herald/herald.service.spec.ts @@ -0,0 +1,525 @@ +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 { + JOB_CREATED, + JOB_STARTED, + JOB_COMPLETED, + JOB_FAILED, + STEP_STARTED, + STEP_COMPLETED, + GATE_PASSED, + GATE_FAILED, +} from "../job-events/event-types"; + +describe("HeraldService", () => { + let service: HeraldService; + let prisma: PrismaService; + let discord: DiscordService; + + const mockPrisma = { + workspace: { + findUnique: vi.fn(), + }, + runnerJob: { + findUnique: vi.fn(), + }, + jobEvent: { + findFirst: vi.fn(), + }, + }; + + const mockDiscord = { + isConnected: vi.fn(), + sendMessage: vi.fn(), + sendThreadMessage: vi.fn(), + createThread: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HeraldService, + { + provide: PrismaService, + useValue: mockPrisma, + }, + { + provide: DiscordService, + useValue: mockDiscord, + }, + ], + }).compile(); + + service = module.get(HeraldService); + prisma = module.get(PrismaService); + discord = module.get(DiscordService); + + // Reset mocks + vi.clearAllMocks(); + }); + + describe("broadcastJobEvent", () => { + it("should broadcast job.created event to configured channel", 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: { issueNumber: 42 }, + }; + + mockPrisma.workspace.findUnique.mockResolvedValue({ + id: workspaceId, + settings: { + herald: { + channelMappings: { + "code-task": "channel-123", + }, + }, + }, + }); + + mockPrisma.runnerJob.findUnique.mockResolvedValue({ + id: jobId, + workspaceId, + type: "code-task", + }); + + mockPrisma.jobEvent.findFirst.mockResolvedValue({ + payload: { + metadata: { issueNumber: 42, threadId: "thread-123" }, + }, + }); + + mockDiscord.isConnected.mockReturnValue(true); + mockDiscord.sendThreadMessage.mockResolvedValue(undefined); + + // Act + await service.broadcastJobEvent(jobId, event); + + // Assert + expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({ + threadId: "thread-123", + content: expect.stringContaining("Job created"), + }); + }); + + it("should broadcast job.started event", async () => { + // Arrange + const workspaceId = "workspace-1"; + const jobId = "job-1"; + const event = { + id: "event-1", + jobId, + type: JOB_STARTED, + timestamp: new Date(), + actor: "system", + 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({ + threadId: "thread-123", + content: expect.stringContaining("Job started"), + }); + }); + + it("should broadcast job.completed event with success message", async () => { + // Arrange + const workspaceId = "workspace-1"; + const jobId = "job-1"; + const event = { + id: "event-1", + jobId, + type: JOB_COMPLETED, + timestamp: new Date(), + actor: "system", + 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({ + threadId: "thread-123", + content: expect.stringContaining("completed"), + }); + }); + + it("should broadcast job.failed event with PDA-friendly language", async () => { + // Arrange + const workspaceId = "workspace-1"; + const jobId = "job-1"; + const event = { + id: "event-1", + jobId, + type: JOB_FAILED, + timestamp: new Date(), + actor: "system", + 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({ + 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]; + expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/); + }); + + it("should skip broadcasting if Discord is not connected", 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.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(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, + type: JOB_CREATED, + timestamp: new Date(), + actor: "system", + 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: {}, // No threadId + }, + }); + + mockDiscord.isConnected.mockReturnValue(true); + + // Act + await service.broadcastJobEvent(jobId, event); + + // Assert + expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled(); + }); + }); + + describe("formatJobEventMessage", () => { + it("should format job.created message with 10-second scannability", () => { + // Arrange + const event = { + id: "event-1", + jobId: "job-1", + type: JOB_CREATED, + timestamp: new Date("2026-01-01T12:00:00Z"), + actor: "system", + payload: { issueNumber: 42 }, + }; + + const job = { + id: "job-1", + type: "code-task", + }; + + const metadata = { issueNumber: 42 }; + + // Act + 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 + }); + + it("should format job.completed message with visual indicator", () => { + // Arrange + const event = { + id: "event-1", + jobId: "job-1", + type: JOB_COMPLETED, + timestamp: new Date("2026-01-01T12:00:00Z"), + actor: "system", + payload: { duration: 120 }, + }; + + const job = { + id: "job-1", + type: "code-task", + }; + + const metadata = { issueNumber: 42 }; + + // Act + const message = service.formatJobEventMessage(event, job, metadata); + + // Assert + expect(message).toMatch(/βœ…|🟒/); + expect(message).toContain("completed"); + expect(message).not.toMatch(/COMPLETED|SUCCESS/); + }); + + it("should format step.completed message", () => { + // Arrange + const event = { + id: "event-1", + jobId: "job-1", + stepId: "step-1", + type: STEP_COMPLETED, + timestamp: new Date("2026-01-01T12:00:00Z"), + actor: "system", + payload: { stepName: "Run tests" }, + }; + + const job = { + id: "job-1", + type: "code-task", + }; + + const metadata = { issueNumber: 42 }; + + // Act + const message = service.formatJobEventMessage(event, job, metadata); + + // Assert + expect(message).toContain("Step completed"); + expect(message).toContain("Run tests"); + }); + + it("should format gate.passed message", () => { + // Arrange + const event = { + id: "event-1", + jobId: "job-1", + type: GATE_PASSED, + timestamp: new Date("2026-01-01T12:00:00Z"), + actor: "system", + payload: { gateName: "build" }, + }; + + const job = { + id: "job-1", + type: "code-task", + }; + + const metadata = { issueNumber: 42 }; + + // Act + const message = service.formatJobEventMessage(event, job, metadata); + + // Assert + expect(message).toContain("Gate passed"); + expect(message).toContain("build"); + }); + + it("should format gate.failed message with PDA-friendly language", () => { + // Arrange + const event = { + id: "event-1", + jobId: "job-1", + type: GATE_FAILED, + timestamp: new Date("2026-01-01T12:00:00Z"), + actor: "system", + payload: { gateName: "test", error: "2 tests failed" }, + }; + + const job = { + id: "job-1", + type: "code-task", + }; + + const metadata = { issueNumber: 42 }; + + // Act + const message = service.formatJobEventMessage(event, job, metadata); + + // Assert + expect(message).toContain("Gate needs attention"); + expect(message).toContain("test"); + expect(message).not.toMatch(/FAILED|ERROR|CRITICAL/); + }); + }); + + describe("getChannelForJobType", () => { + it("should return channel from workspace settings", async () => { + // Arrange + const workspaceId = "workspace-1"; + const jobType = "code-task"; + + mockPrisma.workspace.findUnique.mockResolvedValue({ + id: workspaceId, + settings: { + herald: { + channelMappings: { + "code-task": "channel-123", + }, + }, + }, + }); + + // Act + const channelId = await service.getChannelForJobType(workspaceId, jobType); + + // Assert + expect(channelId).toBe("channel-123"); + }); + + it("should return default channel if job type not mapped", async () => { + // Arrange + const workspaceId = "workspace-1"; + const jobType = "code-task"; + + mockPrisma.workspace.findUnique.mockResolvedValue({ + id: workspaceId, + settings: { + herald: { + channelMappings: {}, + defaultChannel: "default-channel", + }, + }, + }); + + // Act + const channelId = await service.getChannelForJobType(workspaceId, jobType); + + // Assert + expect(channelId).toBe("default-channel"); + }); + + it("should return null if no channel configured", async () => { + // Arrange + const workspaceId = "workspace-1"; + const jobType = "code-task"; + + mockPrisma.workspace.findUnique.mockResolvedValue({ + id: workspaceId, + settings: {}, + }); + + // Act + const channelId = await service.getChannelForJobType(workspaceId, jobType); + + // Assert + expect(channelId).toBeNull(); + }); + }); +}); diff --git a/apps/api/src/herald/herald.service.ts b/apps/api/src/herald/herald.service.ts new file mode 100644 index 0000000..69ee54f --- /dev/null +++ b/apps/api/src/herald/herald.service.ts @@ -0,0 +1,285 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { DiscordService } from "../bridge/discord/discord.service"; +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 + * - Support Discord (via bridge) and PR comments + */ +@Injectable() +export class HeraldService { + private readonly logger = new Logger(HeraldService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly discord: DiscordService + ) {} + + /** + * Broadcast a job event to the appropriate channel + */ + async broadcastJobEvent( + jobId: string, + event: { + id: string; + jobId: string; + stepId?: string | null; + type: string; + timestamp: Date; + actor: string; + payload: unknown; + } + ): Promise { + try { + // 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 | undefined; + const metadata = firstEventPayload?.metadata as Record | 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) { + this.logger.error(`Failed to broadcast event for job ${jobId}:`, error); + } + } + + /** + * 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 { + const payload = event.payload as Record; + 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 { + const workspace = await this.prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { settings: true }, + }); + + if (!workspace) { + return null; + } + + const settings = workspace.settings as Record; + const heraldSettings = settings.herald as Record | undefined; + const channelMappings = heraldSettings?.channelMappings as Record | 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 { + const issue = issueNumber ? `#${String(issueNumber)}` : "task"; + return `🟒 Job created for ${issue}`; + } + + private formatJobStarted( + issueNumber: number | undefined, + _payload: Record + ): string { + const issue = issueNumber ? `#${String(issueNumber)}` : "task"; + return `πŸ”΅ Job started for ${issue}`; + } + + private formatJobCompleted( + issueNumber: number | undefined, + payload: Record + ): 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 { + 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 { + const issue = issueNumber ? `#${String(issueNumber)}` : "task"; + return `⏸️ Job paused for ${issue}`; + } + + private formatStepStarted( + _issueNumber: number | undefined, + payload: Record + ): string { + const stepName = payload.stepName as string | undefined; + return `▢️ Step started: ${stepName ?? "unknown"}`; + } + + private formatStepCompleted( + _issueNumber: number | undefined, + payload: Record + ): string { + const stepName = payload.stepName as string | undefined; + return `βœ… Step completed: ${stepName ?? "unknown"}`; + } + + private formatStepFailed( + _issueNumber: number | undefined, + payload: Record + ): 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 { + const gateName = payload.gateName as string | undefined; + return `βœ… Gate passed: ${gateName ?? "unknown"}`; + } + + private formatGateFailed( + _issueNumber: number | undefined, + payload: Record + ): 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}`; + } +} diff --git a/apps/api/src/herald/index.ts b/apps/api/src/herald/index.ts new file mode 100644 index 0000000..1861711 --- /dev/null +++ b/apps/api/src/herald/index.ts @@ -0,0 +1,2 @@ +export * from "./herald.module"; +export * from "./herald.service"; diff --git a/apps/api/src/job-steps/job-steps.service.ts b/apps/api/src/job-steps/job-steps.service.ts index 9007a87..11ccc36 100644 --- a/apps/api/src/job-steps/job-steps.service.ts +++ b/apps/api/src/job-steps/job-steps.service.ts @@ -145,4 +145,87 @@ export class JobStepsService { }, }); } + + /** + * Start a step - simplified API without jobId + */ + async start(id: string): Promise>> { + 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>> { + 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>> { + 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>> { + return this.findAllByJob(jobId); + } } diff --git a/docs/scratchpads/172-herald-status.md b/docs/scratchpads/172-herald-status.md new file mode 100644 index 0000000..02c8eae --- /dev/null +++ b/docs/scratchpads/172-herald-status.md @@ -0,0 +1,119 @@ +# Issue #172: Herald Status Updates + +## Objective + +Implement status reporting via the bridge module to chat channels and PR comments. The Herald service will broadcast job status updates to appropriate channels based on workspace configuration. + +## Approach + +1. Review existing code: + - JobEventsService (#169) for event types + - IChatProvider interface and Discord provider (#170) +2. Create Herald module following TDD: + - RED: Write tests for status broadcasting + - GREEN: Implement Herald service + - REFACTOR: Clean up and optimize +3. Implement channel selection logic (job type β†’ channel mapping) +4. Add PR comment support via GitHub/Gitea API +5. Format messages using PDA-friendly language + +## Progress + +- [x] Create scratchpad +- [x] Review JobEventsService and event types +- [x] Review IChatProvider interface and Discord provider +- [x] Write tests for Herald service (RED) +- [x] Create Herald module structure +- [x] Implement Herald service (GREEN) +- [x] Add channel selection logic +- [ ] Add PR comment support (deferred - GitHub API integration needed) +- [x] Refactor and optimize (REFACTOR) +- [x] Run quality gates (typecheck, lint, test, build) +- [x] Commit changes + +## Key Findings + +### Event Types Available + +- 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` + +### IChatProvider Interface + +- `sendMessage(channelId, content)` - Send message to channel +- `createThread(options)` - Create thread for updates +- `sendThreadMessage(options)` - Send message to thread +- `isConnected()` - Check connection status + +### Workspace Settings + +- Workspace has `settings` JSON field for configuration +- Can store channel mappings: `{ herald: { channelMappings: { "code-task": "channel-id" } } }` + +### Herald Responsibilities + +1. Subscribe to job events from JobEventsService +2. Format status messages using PDA-friendly language +3. Route to appropriate channels based on workspace config +4. Support Discord (via bridge) and PR comments (via GitHub/Gitea API) +5. Follow 10-second scannability rule + +## Testing + +- Unit tests for status broadcasting +- Tests for channel selection logic +- Tests for message formatting (PDA-friendly) +- Tests for PR comment integration +- Minimum 85% coverage required + +## Notes + +- Use PDA-friendly language (no "OVERDUE", "URGENT", etc.) +- Follow 10-second scannability rule +- Support multiple providers (Discord, GitHub PR comments) +- Subscribe to job events via JobEventsService +- Route to appropriate channels based on workspace config + +## Implementation Details + +### Architecture + +- Herald module created with HeraldService +- Subscribes to job events (job lifecycle, step lifecycle, gate events) +- Formats messages with PDA-friendly language and visual indicators +- Routes to Discord threads via DiscordService + +### Message Formatting + +- 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 +- Job cancelled: ⏸️ Job paused for #42 +- Step completed: βœ… Step completed: Run tests +- Gate passed: βœ… Gate passed: build +- Gate failed: ⚠️ Gate needs attention: test + +### Channel Selection + +- Workspace settings store channel mappings: `{ herald: { channelMappings: { "code-task": "channel-id" } } }` +- Falls back to default channel if job type not mapped +- Returns null if no channel configured + +### Metadata Handling + +- Job metadata (including threadId) stored in first event payload (job.created) +- Herald retrieves metadata from JobEvent table to determine where to send updates +- This allows thread-based updates for each job + +## Deferred Features + +- PR comment support via GitHub/Gitea API (requires additional API client implementation) +- This can be added in a future iteration when needed + +## Dependencies + +- #169 (JobEventsService) - βœ… COMPLETED +- #170 (IChatProvider) - βœ… COMPLETED