diff --git a/apps/api/package.json b/apps/api/package.json index 76d3bd5..e062051 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,6 +36,7 @@ "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12", + "@nestjs/schedule": "^6.1.1", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.12", "@opentelemetry/api": "^1.9.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e58f32c..147bb6f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core"; import { ThrottlerModule } from "@nestjs/throttler"; import { BullModule } from "@nestjs/bullmq"; +import { ScheduleModule } from "@nestjs/schedule"; import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler"; import { CsrfGuard } from "./common/guards/csrf.guard"; import { CsrfService } from "./common/services/csrf.service"; @@ -53,6 +54,7 @@ import { ConversationArchiveModule } from "./conversation-archive/conversation-a import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { AgentConfigModule } from "./agent-config/agent-config.module"; import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module"; +import { ContainerReaperModule } from "./container-reaper/container-reaper.module"; import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module"; import { OnboardingModule } from "./onboarding/onboarding.module"; @@ -85,6 +87,7 @@ import { OnboardingModule } from "./onboarding/onboarding.module"; }; })(), }), + ScheduleModule.forRoot(), TelemetryModule, PrismaModule, DatabaseModule, @@ -129,6 +132,7 @@ import { OnboardingModule } from "./onboarding/onboarding.module"; ConversationArchiveModule, AgentConfigModule, ContainerLifecycleModule, + ContainerReaperModule, FleetSettingsModule, OnboardingModule, ], diff --git a/apps/api/src/container-reaper/container-reaper.module.ts b/apps/api/src/container-reaper/container-reaper.module.ts new file mode 100644 index 0000000..999d202 --- /dev/null +++ b/apps/api/src/container-reaper/container-reaper.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ScheduleModule } from "@nestjs/schedule"; +import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module"; +import { ContainerReaperService } from "./container-reaper.service"; + +@Module({ + imports: [ScheduleModule, ContainerLifecycleModule], + providers: [ContainerReaperService], +}) +export class ContainerReaperModule {} diff --git a/apps/api/src/container-reaper/container-reaper.service.spec.ts b/apps/api/src/container-reaper/container-reaper.service.spec.ts new file mode 100644 index 0000000..411f65b --- /dev/null +++ b/apps/api/src/container-reaper/container-reaper.service.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service"; +import { ContainerReaperService } from "./container-reaper.service"; + +describe("ContainerReaperService", () => { + let service: ContainerReaperService; + let containerLifecycle: Pick; + + beforeEach(() => { + containerLifecycle = { + reapIdle: vi.fn(), + }; + service = new ContainerReaperService(containerLifecycle as ContainerLifecycleService); + }); + + it("reapIdleContainers calls containerLifecycle.reapIdle()", async () => { + vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: [] }); + + await service.reapIdleContainers(); + + expect(containerLifecycle.reapIdle).toHaveBeenCalledTimes(1); + }); + + it("reapIdleContainers handles errors gracefully", async () => { + const error = new Error("reap failure"); + vi.mocked(containerLifecycle.reapIdle).mockRejectedValue(error); + const loggerError = vi.spyOn(service["logger"], "error").mockImplementation(() => {}); + + await expect(service.reapIdleContainers()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + "Failed to reap idle containers", + expect.stringContaining("reap failure") + ); + }); + + it("reapIdleContainers logs stopped container count", async () => { + vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: ["user-1", "user-2"] }); + const loggerLog = vi.spyOn(service["logger"], "log").mockImplementation(() => {}); + + await service.reapIdleContainers(); + + expect(loggerLog).toHaveBeenCalledWith("Stopped 2 idle containers: user-1, user-2"); + }); +}); diff --git a/apps/api/src/container-reaper/container-reaper.service.ts b/apps/api/src/container-reaper/container-reaper.service.ts new file mode 100644 index 0000000..99395c5 --- /dev/null +++ b/apps/api/src/container-reaper/container-reaper.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service"; + +@Injectable() +export class ContainerReaperService { + private readonly logger = new Logger(ContainerReaperService.name); + + constructor(private readonly containerLifecycle: ContainerLifecycleService) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async reapIdleContainers(): Promise { + this.logger.log("Running idle container reap cycle..."); + try { + const result = await this.containerLifecycle.reapIdle(); + if (result.stopped.length > 0) { + this.logger.log( + `Stopped ${String(result.stopped.length)} idle containers: ${result.stopped.join(", ")}` + ); + } else { + this.logger.debug("No idle containers to stop"); + } + } catch (error) { + this.logger.error( + "Failed to reap idle containers", + error instanceof Error ? error.stack : String(error) + ); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32afd3b..74fe90f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@nestjs/platform-socket.io': specifier: ^11.1.12 version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2) + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2) @@ -1741,6 +1744,12 @@ packages: '@nestjs/websockets': ^11.0.0 rxjs: ^7.1.0 + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -3241,6 +3250,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/markdown-it@13.0.9': resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} @@ -4251,6 +4263,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -8863,6 +8879,12 @@ snapshots: - supports-color - utf-8-validate + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': + dependencies: + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -10593,6 +10615,8 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/luxon@3.7.1': {} + '@types/markdown-it@13.0.9': dependencies: '@types/linkify-it': 3.0.5 @@ -11787,6 +11811,11 @@ snapshots: dependencies: luxon: 3.7.2 + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1