diff --git a/packages/backend/src/clusters/clusters.service.ts b/packages/backend/src/clusters/clusters.service.ts index 12748fd..776301c 100644 --- a/packages/backend/src/clusters/clusters.service.ts +++ b/packages/backend/src/clusters/clusters.service.ts @@ -36,14 +36,24 @@ export class ClustersService { } async findAll() { - return await this.prismaService.cluster.findMany({ + const clusters = await this.prismaService.cluster.findMany({ select: { id: true, shardIds: true, status: true, + botId: true, + containerId: true, }, orderBy: { id: 'asc' }, }); + + return await Promise.all( + clusters.map(async (c) => ({ + id: c.id, + shardIds: c.shardIds, + status: await this.dockerService.reconcileClusterStatus(c), + })), + ); } async findOne(id: number) { @@ -52,6 +62,8 @@ export class ClustersService { id: true, shardIds: true, status: true, + botId: true, + containerId: true, }, where: { id }, }); @@ -60,7 +72,11 @@ export class ClustersService { throw new NotFoundException(); } - return resource; + return { + id: resource.id, + shardIds: resource.shardIds, + status: await this.dockerService.reconcileClusterStatus(resource), + }; } async remove(id: number) { diff --git a/packages/backend/src/docker/docker.service.spec.ts b/packages/backend/src/docker/docker.service.spec.ts index 7b5270a..19d3685 100644 --- a/packages/backend/src/docker/docker.service.spec.ts +++ b/packages/backend/src/docker/docker.service.spec.ts @@ -109,4 +109,58 @@ describe('DockerService', () => { await expect(service.start(bot, cluster)).resolves.toBe('new-container-id'); }); }); + + describe('reconcileClusterStatus', () => { + const baseCluster = { id: 0, botId: 'bot-1', containerId: 'container-id' }; + + it('flips DB RUNNING→STOPPED when the container has been stopped out-of-band', async () => { + dockerSocket.apiCall.mockResolvedValueOnce({ State: { Running: false } } as never); + + const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' }); + + expect(status).toBe('STOPPED'); + expect(prismaService.cluster.update).toHaveBeenCalledWith({ + where: { botId_id: { botId: 'bot-1', id: 0 } }, + data: { status: 'STOPPED' }, + }); + }); + + it('clears containerId and marks STOPPED when the container has vanished (404)', async () => { + dockerSocket.apiCall.mockRejectedValueOnce(new DockerAPIHttpError(404, 'no such container')); + + const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' }); + + expect(status).toBe('STOPPED'); + expect(prismaService.cluster.update).toHaveBeenCalledWith({ + where: { botId_id: { botId: 'bot-1', id: 0 } }, + data: { status: 'STOPPED', containerId: null }, + }); + }); + + it('does not touch DB when the actual state matches the stored one', async () => { + dockerSocket.apiCall.mockResolvedValueOnce({ State: { Running: true } } as never); + + const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' }); + + expect(status).toBe('RUNNING'); + expect(prismaService.cluster.update).not.toHaveBeenCalled(); + }); + + it('skips transient states (STARTING/UPDATING/ERROR) without hitting Docker', async () => { + const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'STARTING' }); + + expect(status).toBe('STARTING'); + expect(dockerSocket.apiCall).not.toHaveBeenCalled(); + expect(prismaService.cluster.update).not.toHaveBeenCalled(); + }); + + it('returns current status without throwing when Docker is unreachable', async () => { + dockerSocket.apiCall.mockRejectedValueOnce(new DockerAPIHttpError(500, 'socket error')); + + const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' }); + + expect(status).toBe('RUNNING'); + expect(prismaService.cluster.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/docker/docker.service.ts b/packages/backend/src/docker/docker.service.ts index 192407f..043a412 100644 --- a/packages/backend/src/docker/docker.service.ts +++ b/packages/backend/src/docker/docker.service.ts @@ -8,7 +8,7 @@ import { type DockerContainerCreated, type DockerContainerCreationBody, } from '@hallmaster/docker.js'; -import { Bot, Cluster, DockerImage } from '@hallmaster/prisma-client'; +import { Bot, Cluster, ClusterStatus, DockerImage } from '@hallmaster/prisma-client'; import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -69,6 +69,44 @@ export class DockerService { } } + async reconcileClusterStatus(cluster: { + id: number; + botId: string; + containerId: string | null; + status: ClusterStatus; + }): Promise { + if (cluster.status !== 'RUNNING' && cluster.status !== 'STOPPED') { + return cluster.status; + } + if (cluster.containerId === null) { + return cluster.status; + } + + const api = new DockerContainersAPI(this.dockerSocket); + + try { + const container = await api.get(cluster.containerId); + const actual: ClusterStatus = container.State.Running ? 'RUNNING' : 'STOPPED'; + + if (actual !== cluster.status) { + await this.prismaService.cluster.update({ + where: { botId_id: { botId: cluster.botId, id: cluster.id } }, + data: { status: actual }, + }); + } + return actual; + } catch (e) { + if (this.isContainerNotFound(e)) { + await this.prismaService.cluster.update({ + where: { botId_id: { botId: cluster.botId, id: cluster.id } }, + data: { status: 'STOPPED', containerId: null }, + }); + return 'STOPPED'; + } + return cluster.status; + } + } + async verifyImage(dockerImage: { serverName: string; image: string;