diff --git a/apps/api/src/runner-jobs/runner-jobs.module.ts b/apps/api/src/runner-jobs/runner-jobs.module.ts index 828fff2..0cda3f7 100644 --- a/apps/api/src/runner-jobs/runner-jobs.module.ts +++ b/apps/api/src/runner-jobs/runner-jobs.module.ts @@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service"; import { PrismaModule } from "../prisma/prisma.module"; import { BullMqModule } from "../bullmq/bullmq.module"; import { AuthModule } from "../auth/auth.module"; +import { WebSocketModule } from "../websocket/websocket.module"; /** * Runner Jobs Module @@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module"; * for asynchronous job processing. */ @Module({ - imports: [PrismaModule, BullMqModule, AuthModule], + imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule], controllers: [RunnerJobsController], providers: [RunnerJobsService], exports: [RunnerJobsService], diff --git a/apps/api/src/runner-jobs/runner-jobs.service.concurrency.spec.ts b/apps/api/src/runner-jobs/runner-jobs.service.concurrency.spec.ts index c5b4d54..9e96baf 100644 --- a/apps/api/src/runner-jobs/runner-jobs.service.concurrency.spec.ts +++ b/apps/api/src/runner-jobs/runner-jobs.service.concurrency.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { RunnerJobsService } from "./runner-jobs.service"; import { PrismaService } from "../prisma/prisma.service"; import { BullMqService } from "../bullmq/bullmq.service"; +import { WebSocketGateway } from "../websocket/websocket.gateway"; import { RunnerJobStatus } from "@prisma/client"; import { ConflictException, BadRequestException } from "@nestjs/common"; @@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => { getQueue: vi.fn(), }; + const mockWebSocketGateway = { + emitJobCreated: vi.fn(), + emitJobStatusChanged: vi.fn(), + emitJobProgress: vi.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => { provide: BullMqService, useValue: mockBullMqService, }, + { + provide: WebSocketGateway, + useValue: mockWebSocketGateway, + }, ], }).compile(); diff --git a/apps/api/src/runner-jobs/runner-jobs.service.spec.ts b/apps/api/src/runner-jobs/runner-jobs.service.spec.ts index 2632192..ac0ca10 100644 --- a/apps/api/src/runner-jobs/runner-jobs.service.spec.ts +++ b/apps/api/src/runner-jobs/runner-jobs.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { RunnerJobsService } from "./runner-jobs.service"; import { PrismaService } from "../prisma/prisma.service"; import { BullMqService } from "../bullmq/bullmq.service"; +import { WebSocketGateway } from "../websocket/websocket.gateway"; import { RunnerJobStatus } from "@prisma/client"; import { NotFoundException, BadRequestException } from "@nestjs/common"; import { CreateJobDto, QueryJobsDto } from "./dto"; @@ -32,6 +33,12 @@ describe("RunnerJobsService", () => { getQueue: vi.fn(), }; + const mockWebSocketGateway = { + emitJobCreated: vi.fn(), + emitJobStatusChanged: vi.fn(), + emitJobProgress: vi.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -44,6 +51,10 @@ describe("RunnerJobsService", () => { provide: BullMqService, useValue: mockBullMqService, }, + { + provide: WebSocketGateway, + useValue: mockWebSocketGateway, + }, ], }).compile(); diff --git a/apps/api/src/runner-jobs/runner-jobs.service.ts b/apps/api/src/runner-jobs/runner-jobs.service.ts index 8149a23..02d340d 100644 --- a/apps/api/src/runner-jobs/runner-jobs.service.ts +++ b/apps/api/src/runner-jobs/runner-jobs.service.ts @@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client"; import { Response } from "express"; import { PrismaService } from "../prisma/prisma.service"; import { BullMqService } from "../bullmq/bullmq.service"; +import { WebSocketGateway } from "../websocket/websocket.gateway"; import { QUEUE_NAMES } from "../bullmq/queues"; import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception"; import type { CreateJobDto, QueryJobsDto } from "./dto"; @@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto"; export class RunnerJobsService { constructor( private readonly prisma: PrismaService, - private readonly bullMq: BullMqService + private readonly bullMq: BullMqService, + private readonly wsGateway: WebSocketGateway ) {} /** @@ -56,6 +58,8 @@ export class RunnerJobsService { { priority } ); + this.wsGateway.emitJobCreated(workspaceId, job); + return job; } @@ -194,6 +198,13 @@ export class RunnerJobsService { throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`); } + this.wsGateway.emitJobStatusChanged(workspaceId, id, { + id, + workspaceId, + status: job.status, + previousStatus: existingJob.status, + }); + return job; }); } @@ -248,6 +259,8 @@ export class RunnerJobsService { { priority: existingJob.priority } ); + this.wsGateway.emitJobCreated(workspaceId, newJob); + return newJob; } @@ -530,6 +543,13 @@ export class RunnerJobsService { throw new NotFoundException(`RunnerJob with ID ${id} not found after update`); } + this.wsGateway.emitJobStatusChanged(workspaceId, id, { + id, + workspaceId, + status: updatedJob.status, + previousStatus: existingJob.status, + }); + return updatedJob; }); } @@ -606,6 +626,12 @@ export class RunnerJobsService { throw new NotFoundException(`RunnerJob with ID ${id} not found after update`); } + this.wsGateway.emitJobProgress(workspaceId, id, { + id, + workspaceId, + progressPercent: updatedJob.progressPercent, + }); + return updatedJob; }); } diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx index 2105b48..dbaf667 100644 --- a/apps/web/src/app/(authenticated)/page.tsx +++ b/apps/web/src/app/(authenticated)/page.tsx @@ -53,6 +53,28 @@ export default function DashboardPage(): ReactElement { }; }, [workspaceId]); + useEffect(() => { + if (!workspaceId) return; + + let cancelled = false; + const wsId = workspaceId; + + const interval = setInterval(() => { + fetchDashboardSummary(wsId) + .then((summary) => { + if (!cancelled) setData(summary); + }) + .catch((err: unknown) => { + console.error("[Dashboard] Refresh failed:", err); + }); + }, 30_000); + + return (): void => { + cancelled = true; + clearInterval(interval); + }; + }, [workspaceId]); + if (isLoading) { return (
diff --git a/apps/web/src/components/dashboard/OrchestratorSessions.tsx b/apps/web/src/components/dashboard/OrchestratorSessions.tsx index e654163..40e6659 100644 --- a/apps/web/src/components/dashboard/OrchestratorSessions.tsx +++ b/apps/web/src/components/dashboard/OrchestratorSessions.tsx @@ -27,6 +27,7 @@ interface AgentNode { name: string; task: string; status: DotVariant; + statusLabel: string; } interface OrchestratorSession { @@ -36,6 +37,7 @@ interface OrchestratorSession { badge: string; badgeVariant: BadgeVariant; duration: string; + progress: number; agents: AgentNode[]; } @@ -105,6 +107,7 @@ function mapJobToSession(job: ActiveJob): OrchestratorSession { name: step.name, task: `Phase: ${step.phase}`, status: statusToDotVariant(step.status), + statusLabel: step.status.toLowerCase(), })); return { @@ -114,6 +117,7 @@ function mapJobToSession(job: ActiveJob): OrchestratorSession { badge: job.status, badgeVariant: statusToBadgeVariant(job.status), duration: formatDuration(job.createdAt), + progress: job.progressPercent, agents, }; } @@ -192,6 +196,16 @@ function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
+ + {agent.statusLabel} + ); } @@ -251,6 +265,27 @@ function OrchCard({ session }: OrchCardProps): ReactElement { {session.duration} + {session.progress > 0 && ( +
+
+
+ )}
{session.agents.map((agent) => ( diff --git a/docs/TASKS.md b/docs/TASKS.md index 8a3d026..660145b 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -11,6 +11,6 @@ | MS-P2-001 | done | phase-2 | Create dashboard summary API endpoint: aggregate task counts, project counts, recent activity, active jobs in single call | — | issue #459, commit e38aaa9, 7 files +430 lines | | MS-P2-002 | done | phase-2 | Wire dashboard widgets to real API data: ActivityFeed, DashboardMetrics, OrchestratorSessions replace mock with API calls | — | issue #459, commit 7c762e6 + remediation | | MS-P2-003 | done | phase-2 | Phase verification: create task via API, confirm visible in dashboard, all quality gates pass | — | issue #459, lint 8/8 typecheck 7/7 test 8/8 | -| MS-P3-001 | not-started | phase-3 | Wire WebSocket emits into RunnerJobsService: broadcast job status/progress/step events to workspace rooms | — | issue #461, est 20K | -| MS-P3-002 | not-started | phase-3 | Dashboard auto-refresh + enhanced OrchestratorSessions: polling interval, progress bars, step status indicators, timestamps | — | issue #461, est 25K | +| MS-P3-001 | in-progress | phase-3 | Wire WebSocket emits into RunnerJobsService: broadcast job status/progress/step events to workspace rooms | — | issue #461, est 20K | +| MS-P3-002 | in-progress | phase-3 | Dashboard auto-refresh + enhanced OrchestratorSessions: polling interval, progress bars, step status indicators, timestamps | — | issue #461, est 25K | | MS-P3-003 | not-started | phase-3 | Phase verification: all quality gates pass, demonstrate agent job cycle visibility end-to-end | — | issue #461, est 10K, depends MS-P3-001+002 |