import { Controller, Get, Post, Body, Param, Query, UseGuards, Res, Headers } from "@nestjs/common"; import { Response } from "express"; import { RunnerJobsService } from "./runner-jobs.service"; import { CreateJobDto, QueryJobsDto } from "./dto"; import { AuthGuard } from "../auth/guards/auth.guard"; import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { Workspace, Permission, RequirePermission } from "../common/decorators"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; import type { AuthenticatedUser } from "../common/types/user.types"; /** * Controller for runner job endpoints * All endpoints require authentication and workspace context * * Guards are applied in order: * 1. AuthGuard - Verifies user authentication * 2. WorkspaceGuard - Validates workspace access and sets RLS context * 3. PermissionGuard - Checks role-based permissions */ @Controller("runner-jobs") @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) export class RunnerJobsController { constructor(private readonly runnerJobsService: RunnerJobsService) {} /** * POST /api/runner-jobs * Create a new runner job and queue it * Requires: MEMBER role or higher */ @Post() @RequirePermission(Permission.WORKSPACE_MEMBER) async create( @Body() createJobDto: CreateJobDto, @Workspace() workspaceId: string, @CurrentUser() _user: AuthenticatedUser ) { return this.runnerJobsService.create(workspaceId, createJobDto); } /** * GET /api/runner-jobs * Get paginated jobs with optional filters * Requires: Any workspace member (including GUEST) */ @Get() @RequirePermission(Permission.WORKSPACE_ANY) async findAll(@Query() query: QueryJobsDto, @Workspace() workspaceId: string) { return this.runnerJobsService.findAll(Object.assign({}, query, { workspaceId })); } /** * GET /api/runner-jobs/:id * Get a single job by ID * Requires: Any workspace member */ @Get(":id") @RequirePermission(Permission.WORKSPACE_ANY) async findOne(@Param("id") id: string, @Workspace() workspaceId: string) { return this.runnerJobsService.findOne(id, workspaceId); } /** * POST /api/runner-jobs/:id/cancel * Cancel a running or queued job * Requires: MEMBER role or higher */ @Post(":id/cancel") @RequirePermission(Permission.WORKSPACE_MEMBER) async cancel( @Param("id") id: string, @Workspace() workspaceId: string, @CurrentUser() _user: AuthenticatedUser ) { return this.runnerJobsService.cancel(id, workspaceId); } /** * POST /api/runner-jobs/:id/retry * Retry a failed job * Requires: MEMBER role or higher */ @Post(":id/retry") @RequirePermission(Permission.WORKSPACE_MEMBER) async retry( @Param("id") id: string, @Workspace() workspaceId: string, @CurrentUser() _user: AuthenticatedUser ) { return this.runnerJobsService.retry(id, workspaceId); } /** * GET /api/runner-jobs/:id/events/stream * Stream job events via Server-Sent Events (SSE) * Requires: Any workspace member * Supports automatic reconnection via Last-Event-ID header */ @Get(":id/events/stream") @RequirePermission(Permission.WORKSPACE_ANY) async streamEvents( @Param("id") id: string, @Workspace() workspaceId: string, @Headers("last-event-id") lastEventId: string | undefined, @Res() res: Response ): Promise { // Set SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering try { await this.runnerJobsService.streamEvents(id, workspaceId, res, lastEventId); } catch (error: unknown) { // Write error to stream const errorMessage = error instanceof Error ? error.message : String(error); res.write(`event: error\n`); res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`); res.end(); } } }