feat(scenario): add scheduler, cancelled step status, and runs list endpoint
- install @nestjs/schedule and register ScheduleModule in AppModule
- add ScenarioSchedulerService with two 1s interval jobs:
- activatePendingRuns: flips pending runs to in_progress
- processPendingStepRuns: executes next pending step run (login or exec),
runs optional validateCode, advances or fails the run accordingly
- on step pass: next waiting step becomes pending; last step pass flips run to pass
- on step fail: remaining steps set to cancelled, run flipped to fail
- add cancelled to RunStepStatus union
- add GET /scenarios/:id/runs with pagination and optional ?status= filter
This commit is contained in:
Generated
+46
@@ -15,6 +15,7 @@
|
|||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.1.18",
|
"@nestjs/core": "^11.1.18",
|
||||||
"@nestjs/platform-express": "^11.1.18",
|
"@nestjs/platform-express": "^11.1.18",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.16.0",
|
||||||
@@ -1174,6 +1175,19 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/schedule": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron": "4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.0.10",
|
"version": "11.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz",
|
||||||
@@ -1468,6 +1482,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||||
|
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mozilla__readability": {
|
"node_modules/@types/mozilla__readability": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mozilla__readability/-/mozilla__readability-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mozilla__readability/-/mozilla__readability-0.4.2.tgz",
|
||||||
@@ -2445,6 +2465,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/luxon": "~3.7.0",
|
||||||
|
"luxon": "~3.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.x"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/intcreator"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3836,6 +3873,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.1.18",
|
"@nestjs/core": "^11.1.18",
|
||||||
"@nestjs/platform-express": "^11.1.18",
|
"@nestjs/platform-express": "^11.1.18",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.16.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { HealthController } from './health/health.controller';
|
import { HealthController } from './health/health.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BrowserModule } from './browser/browser.module';
|
import { BrowserModule } from './browser/browser.module';
|
||||||
@@ -20,6 +21,7 @@ import { ScenarioModule } from './scenario/scenario.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
|
||||||
|
import { RunStatus } from '../scenario-run.entity';
|
||||||
|
|
||||||
|
export class RunsQueryDto {
|
||||||
|
@ApiPropertyOptional({ example: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 20, default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['pending', 'in_progress', 'pass', 'fail'],
|
||||||
|
description: 'Filter by run status',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['pending', 'in_progress', 'pass', 'fail'])
|
||||||
|
status?: RunStatus;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { ScenarioRunEntity } from './scenario-run.entity';
|
import { ScenarioRunEntity } from './scenario-run.entity';
|
||||||
import { ScenarioStepEntity } from './scenario-step.entity';
|
import { ScenarioStepEntity } from './scenario-step.entity';
|
||||||
|
|
||||||
export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fail';
|
export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fail' | 'cancelled';
|
||||||
|
|
||||||
@Entity('scenario_run_steps')
|
@Entity('scenario_run_steps')
|
||||||
export class ScenarioRunStepEntity {
|
export class ScenarioRunStepEntity {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { ScenarioRunEntity } from './scenario-run.entity';
|
||||||
|
import { ScenarioRunStepEntity } from './scenario-run-step.entity';
|
||||||
|
import { ScenarioStepEntity } from './scenario-step.entity';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { CodeExecutorService } from '../code-executor/code-executor.service';
|
||||||
|
import { SessionService } from '../session/session.service';
|
||||||
|
|
||||||
|
interface ValidateResult {
|
||||||
|
success: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ScenarioSchedulerService {
|
||||||
|
private readonly logger = new Logger(ScenarioSchedulerService.name);
|
||||||
|
private isProcessingStep = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ScenarioRunEntity)
|
||||||
|
private readonly runRepo: Repository<ScenarioRunEntity>,
|
||||||
|
@InjectRepository(ScenarioRunStepEntity)
|
||||||
|
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly codeExecutor: CodeExecutorService,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Job 1: flip pending runs → in_progress ─────────────────────────────────
|
||||||
|
|
||||||
|
@Interval(1000)
|
||||||
|
async activatePendingRuns(): Promise<void> {
|
||||||
|
const pending = await this.runRepo.find({ where: { status: 'pending' } });
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
for (const run of pending) {
|
||||||
|
run.status = 'in_progress';
|
||||||
|
await this.runRepo.save(run);
|
||||||
|
this.logger.log(`Run #${run.id} → in_progress`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job 2: process one pending step run per tick ───────────────────────────
|
||||||
|
|
||||||
|
@Interval(1000)
|
||||||
|
async processPendingStepRuns(): Promise<void> {
|
||||||
|
if (this.isProcessingStep) return;
|
||||||
|
this.isProcessingStep = true;
|
||||||
|
try {
|
||||||
|
await this.processNextPendingStep();
|
||||||
|
} finally {
|
||||||
|
this.isProcessingStep = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processNextPendingStep(): Promise<void> {
|
||||||
|
const stepRun = await this.runStepRepo.findOne({
|
||||||
|
where: { status: 'pending' },
|
||||||
|
relations: ['scenarioStep'],
|
||||||
|
order: { order: 'ASC' },
|
||||||
|
});
|
||||||
|
if (!stepRun) return;
|
||||||
|
|
||||||
|
const step = stepRun.scenarioStep as ScenarioStepEntity;
|
||||||
|
|
||||||
|
// Mark in_progress
|
||||||
|
stepRun.status = 'in_progress';
|
||||||
|
await this.runStepRepo.save(stepRun);
|
||||||
|
this.logger.log(`StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (step.type === 'login') {
|
||||||
|
await this.executeLoginStep(stepRun, step);
|
||||||
|
} else {
|
||||||
|
await this.executeExecStep(stepRun, step);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message ?? String(err);
|
||||||
|
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
||||||
|
await this.failStepRun(stepRun, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login step ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async executeLoginStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
||||||
|
// execCode must be a JSON object: { "keyId": "...", "environmentName": "..." }
|
||||||
|
let params: { keyId: string; environmentName: string };
|
||||||
|
try {
|
||||||
|
params = JSON.parse(step.execCode ?? '{}');
|
||||||
|
} catch {
|
||||||
|
throw new Error('login step execCode must be valid JSON with keyId and environmentName');
|
||||||
|
}
|
||||||
|
if (!params.keyId || !params.environmentName) {
|
||||||
|
throw new Error('login step execCode must include keyId and environmentName');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginResult = await this.authService.login(params.keyId, params.environmentName, step.sessionName);
|
||||||
|
this.logger.log(`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`);
|
||||||
|
|
||||||
|
// Optional validation
|
||||||
|
if (step.validateCode) {
|
||||||
|
const validateResult = await this.runValidateCode(step.sessionName, step.validateCode);
|
||||||
|
if (!validateResult.success) {
|
||||||
|
throw new Error(validateResult.description ?? 'Validation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.passStepRun(stepRun, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exec step ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async executeExecStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
||||||
|
if (!step.execCode) {
|
||||||
|
throw new Error('exec step has no execCode');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.codeExecutor.validate(step.execCode);
|
||||||
|
await this.runExecCode(step.sessionName, step.execCode);
|
||||||
|
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
|
||||||
|
|
||||||
|
// Optional validation
|
||||||
|
if (step.validateCode) {
|
||||||
|
const validateResult = await this.runValidateCode(step.sessionName, step.validateCode);
|
||||||
|
if (!validateResult.success) {
|
||||||
|
throw new Error(validateResult.description ?? 'Validation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.passStepRun(stepRun, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Browser helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async runExecCode(sessionName: string, code: string): Promise<unknown> {
|
||||||
|
const session = await this.sessionService.findBySessionName(sessionName);
|
||||||
|
if (!session) throw new Error(`Session not found: ${sessionName}`);
|
||||||
|
|
||||||
|
const cookies: unknown[] = JSON.parse(session.cookies);
|
||||||
|
const localStorageData: Record<string, string> = JSON.parse(session.localStorage);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
await context.addCookies(cookies as Parameters<typeof context.addCookies>[0]);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await context.addInitScript((entries: Record<string, string>) => {
|
||||||
|
for (const [k, v] of Object.entries(entries)) window.localStorage.setItem(k, v);
|
||||||
|
}, localStorageData);
|
||||||
|
const { result } = await this.codeExecutor.execute(page, context, code);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runValidateCode(sessionName: string, validateCode: string): Promise<ValidateResult> {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await this.runExecCode(sessionName, validateCode);
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, description: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'boolean') return { success: raw };
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
success: Boolean(r['success']),
|
||||||
|
description: r['description'] != null ? String(r['description']) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Anything truthy = pass
|
||||||
|
return { success: Boolean(raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass / fail helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async passStepRun(stepRun: ScenarioRunStepEntity, description: string | null): Promise<void> {
|
||||||
|
stepRun.status = 'pass';
|
||||||
|
stepRun.description = description;
|
||||||
|
await this.runStepRepo.save(stepRun);
|
||||||
|
this.logger.log(`StepRun #${stepRun.id} → pass`);
|
||||||
|
|
||||||
|
// Find the next waiting step in this run (next by order)
|
||||||
|
const nextStep = await this.runStepRepo.findOne({
|
||||||
|
where: { runId: stepRun.runId, status: 'waiting' },
|
||||||
|
order: { order: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextStep) {
|
||||||
|
nextStep.status = 'pending';
|
||||||
|
await this.runStepRepo.save(nextStep);
|
||||||
|
this.logger.log(`StepRun #${nextStep.id} → pending (next in sequence)`);
|
||||||
|
} else {
|
||||||
|
// No more waiting steps — check if any are still in_progress/pending (shouldn't be, but guard anyway)
|
||||||
|
const remaining = await this.runStepRepo.count({
|
||||||
|
where: [
|
||||||
|
{ runId: stepRun.runId, status: 'pending' },
|
||||||
|
{ runId: stepRun.runId, status: 'in_progress' },
|
||||||
|
{ runId: stepRun.runId, status: 'waiting' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (remaining === 0) {
|
||||||
|
await this.runRepo.update(stepRun.runId, { status: 'pass' });
|
||||||
|
this.logger.log(`Run #${stepRun.runId} → pass (all steps passed)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async failStepRun(stepRun: ScenarioRunStepEntity, description: string): Promise<void> {
|
||||||
|
stepRun.status = 'fail';
|
||||||
|
stepRun.description = description;
|
||||||
|
await this.runStepRepo.save(stepRun);
|
||||||
|
this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`);
|
||||||
|
|
||||||
|
// Cancel all remaining waiting/pending step runs in this run
|
||||||
|
await this.runStepRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({ status: 'cancelled' })
|
||||||
|
.where('runId = :runId AND status IN (:...statuses)', {
|
||||||
|
runId: stepRun.runId,
|
||||||
|
statuses: ['waiting', 'pending'],
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`);
|
||||||
|
|
||||||
|
await this.runRepo.update(stepRun.runId, { status: 'fail' });
|
||||||
|
this.logger.log(`Run #${stepRun.runId} → fail`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { UpdateScenarioDto } from './dto/update-scenario.dto';
|
|||||||
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
||||||
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
||||||
import { PaginationQueryDto } from './dto/pagination-query.dto';
|
import { PaginationQueryDto } from './dto/pagination-query.dto';
|
||||||
|
import { RunsQueryDto } from './dto/runs-query.dto';
|
||||||
|
|
||||||
@ApiTags('scenarios')
|
@ApiTags('scenarios')
|
||||||
@Controller('scenarios')
|
@Controller('scenarios')
|
||||||
@@ -114,6 +115,17 @@ export class ScenarioController {
|
|||||||
|
|
||||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get(':id/runs')
|
||||||
|
@ApiOperation({ summary: 'List runs for a scenario (paginated, filterable by status)' })
|
||||||
|
@ApiResponse({ status: 200 })
|
||||||
|
@ApiResponse({ status: 404, description: 'Scenario not found' })
|
||||||
|
findRuns(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query() query: RunsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.scenarioService.findRuns(id, query);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/run')
|
@Post(':id/run')
|
||||||
@ApiOperation({ summary: 'Create a new run for a scenario' })
|
@ApiOperation({ summary: 'Create a new run for a scenario' })
|
||||||
@ApiResponse({ status: 201, description: 'Run created with step runs' })
|
@ApiResponse({ status: 201, description: 'Run created with step runs' })
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import { ScenarioRunEntity } from './scenario-run.entity';
|
|||||||
import { ScenarioRunStepEntity } from './scenario-run-step.entity';
|
import { ScenarioRunStepEntity } from './scenario-run-step.entity';
|
||||||
import { ScenarioService } from './scenario.service';
|
import { ScenarioService } from './scenario.service';
|
||||||
import { ScenarioController } from './scenario.controller';
|
import { ScenarioController } from './scenario.controller';
|
||||||
|
import { ScenarioSchedulerService } from './scenario-scheduler.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { CodeExecutorModule } from '../code-executor/code-executor.module';
|
||||||
|
import { SessionModule } from '../session/session.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity]),
|
||||||
|
AuthModule,
|
||||||
|
CodeExecutorModule,
|
||||||
|
SessionModule,
|
||||||
|
],
|
||||||
controllers: [ScenarioController],
|
controllers: [ScenarioController],
|
||||||
providers: [ScenarioService],
|
providers: [ScenarioService, ScenarioSchedulerService],
|
||||||
exports: [ScenarioService],
|
exports: [ScenarioService],
|
||||||
})
|
})
|
||||||
export class ScenarioModule {}
|
export class ScenarioModule {}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UpdateScenarioDto } from './dto/update-scenario.dto';
|
|||||||
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
||||||
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
||||||
import { PaginationQueryDto } from './dto/pagination-query.dto';
|
import { PaginationQueryDto } from './dto/pagination-query.dto';
|
||||||
|
import { RunsQueryDto } from './dto/runs-query.dto';
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
@@ -100,7 +101,21 @@ export class ScenarioService {
|
|||||||
await this.stepRepo.delete(stepId);
|
await this.stepRepo.delete(stepId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
async findRuns(scenarioId: number, query: RunsQueryDto): Promise<PaginatedResult<ScenarioRunEntity>> {
|
||||||
|
await this.findOne(scenarioId); // 404 guard
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const where: Record<string, unknown> = { scenarioId };
|
||||||
|
if (query.status) where['status'] = query.status;
|
||||||
|
const [data, total] = await this.runRepo.findAndCount({
|
||||||
|
where,
|
||||||
|
relations: ['stepRuns'],
|
||||||
|
order: { id: 'DESC', stepRuns: { order: 'ASC' } },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return { data, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
async createRun(scenarioId: number): Promise<ScenarioRunEntity> {
|
async createRun(scenarioId: number): Promise<ScenarioRunEntity> {
|
||||||
const scenario = await this.findOne(scenarioId);
|
const scenario = await this.findOne(scenarioId);
|
||||||
|
|||||||
Reference in New Issue
Block a user