From caf120741db27fdfb645b797e38bb12424f28443 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 7 Apr 2026 14:27:17 +0300 Subject: [PATCH] feat(scenario): add scenario runs, run steps, and step ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add ScenarioRunEntity (pending|in_progress|pass|fail) and ScenarioRunStepEntity (waiting|pending|in_progress|pass|fail) - add POST /scenarios/:id/run — creates run with first step pending, rest waiting - add order field to ScenarioStepEntity and ScenarioRunStepEntity for deterministic sequential execution - findOne and createRun now sort steps/stepRuns by order ASC --- src/app.module.ts | 4 +- src/scenario/dto/create-scenario-step.dto.ts | 7 ++- src/scenario/dto/update-scenario-step.dto.ts | 8 +++- src/scenario/scenario-run-step.entity.ts | 48 ++++++++++++++++++++ src/scenario/scenario-run.entity.ts | 42 +++++++++++++++++ src/scenario/scenario-step.entity.ts | 3 ++ src/scenario/scenario.controller.ts | 10 ++++ src/scenario/scenario.module.ts | 4 +- src/scenario/scenario.service.ts | 37 ++++++++++++++- 9 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/scenario/scenario-run-step.entity.ts create mode 100644 src/scenario/scenario-run.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index 4648eb3..f47e855 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,8 @@ import { EnvironmentModule } from './environment/environment.module'; import { McpModule } from './mcp/mcp.module'; import { ScenarioEntity } from './scenario/scenario.entity'; import { ScenarioStepEntity } from './scenario/scenario-step.entity'; +import { ScenarioRunEntity } from './scenario/scenario-run.entity'; +import { ScenarioRunStepEntity } from './scenario/scenario-run-step.entity'; import { ScenarioModule } from './scenario/scenario.module'; @Module({ @@ -23,7 +25,7 @@ import { ScenarioModule } from './scenario/scenario.module'; useFactory: (config: ConfigService) => ({ type: 'better-sqlite3', database: config.get('DB_PATH', 'data/sessions.db'), - entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity], + entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity], synchronize: true, }), }), diff --git a/src/scenario/dto/create-scenario-step.dto.ts b/src/scenario/dto/create-scenario-step.dto.ts index 621898f..2006756 100644 --- a/src/scenario/dto/create-scenario-step.dto.ts +++ b/src/scenario/dto/create-scenario-step.dto.ts @@ -1,8 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; import { StepType } from '../scenario-step.entity'; export class CreateScenarioStepDto { + @ApiProperty({ example: 0, description: 'Execution order (ascending)' }) + @IsInt() + @Min(0) + order: number; + @ApiProperty({ enum: ['login', 'exec'], example: 'exec' }) @IsIn(['login', 'exec']) type: StepType; diff --git a/src/scenario/dto/update-scenario-step.dto.ts b/src/scenario/dto/update-scenario-step.dto.ts index 323a4cb..12290bf 100644 --- a/src/scenario/dto/update-scenario-step.dto.ts +++ b/src/scenario/dto/update-scenario-step.dto.ts @@ -1,8 +1,14 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; import { StepType } from '../scenario-step.entity'; export class UpdateScenarioStepDto { + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @IsInt() + @Min(0) + order?: number; + @ApiPropertyOptional({ enum: ['login', 'exec'] }) @IsOptional() @IsIn(['login', 'exec']) diff --git a/src/scenario/scenario-run-step.entity.ts b/src/scenario/scenario-run-step.entity.ts new file mode 100644 index 0000000..de11fc1 --- /dev/null +++ b/src/scenario/scenario-run-step.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ScenarioRunEntity } from './scenario-run.entity'; +import { ScenarioStepEntity } from './scenario-step.entity'; + +export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fail'; + +@Entity('scenario_run_steps') +export class ScenarioRunStepEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + runId: number; + + @ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'runId' }) + run: ScenarioRunEntity; + + @Column() + scenarioStepId: number; + + @ManyToOne(() => ScenarioStepEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'scenarioStepId' }) + scenarioStep: ScenarioStepEntity; + + @Column({ type: 'text', default: 'waiting' }) + status: RunStepStatus; + + @Column({ default: 0 }) + order: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/scenario/scenario-run.entity.ts b/src/scenario/scenario-run.entity.ts new file mode 100644 index 0000000..890f8f9 --- /dev/null +++ b/src/scenario/scenario-run.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { ScenarioEntity } from './scenario.entity'; +import { ScenarioRunStepEntity } from './scenario-run-step.entity'; + +export type RunStatus = 'pending' | 'in_progress' | 'pass' | 'fail'; + +@Entity('scenario_runs') +export class ScenarioRunEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + scenarioId: number; + + @ManyToOne(() => ScenarioEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'scenarioId' }) + scenario: ScenarioEntity; + + @Column({ type: 'text', default: 'pending' }) + status: RunStatus; + + @OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, { + cascade: true, + eager: false, + }) + stepRuns: ScenarioRunStepEntity[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/scenario/scenario-step.entity.ts b/src/scenario/scenario-step.entity.ts index 9d8ca6b..5841078 100644 --- a/src/scenario/scenario-step.entity.ts +++ b/src/scenario/scenario-step.entity.ts @@ -25,6 +25,9 @@ export class ScenarioStepEntity { @JoinColumn({ name: 'scenarioId' }) scenario: ScenarioEntity; + @Column({ default: 0 }) + order: number; + @Column({ type: 'text' }) type: StepType; diff --git a/src/scenario/scenario.controller.ts b/src/scenario/scenario.controller.ts index 21465e8..b7402b5 100644 --- a/src/scenario/scenario.controller.ts +++ b/src/scenario/scenario.controller.ts @@ -111,4 +111,14 @@ export class ScenarioController { ) { return this.scenarioService.removeStep(id, stepId); } + + // ── Runs ────────────────────────────────────────────────────────────────── + + @Post(':id/run') + @ApiOperation({ summary: 'Create a new run for a scenario' }) + @ApiResponse({ status: 201, description: 'Run created with step runs' }) + @ApiResponse({ status: 404, description: 'Scenario not found' }) + createRun(@Param('id', ParseIntPipe) id: number) { + return this.scenarioService.createRun(id); + } } diff --git a/src/scenario/scenario.module.ts b/src/scenario/scenario.module.ts index b208e9c..1479816 100644 --- a/src/scenario/scenario.module.ts +++ b/src/scenario/scenario.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScenarioEntity } from './scenario.entity'; import { ScenarioStepEntity } from './scenario-step.entity'; +import { ScenarioRunEntity } from './scenario-run.entity'; +import { ScenarioRunStepEntity } from './scenario-run-step.entity'; import { ScenarioService } from './scenario.service'; import { ScenarioController } from './scenario.controller'; @Module({ - imports: [TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity])], + imports: [TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity])], controllers: [ScenarioController], providers: [ScenarioService], exports: [ScenarioService], diff --git a/src/scenario/scenario.service.ts b/src/scenario/scenario.service.ts index 7e58759..a0889dc 100644 --- a/src/scenario/scenario.service.ts +++ b/src/scenario/scenario.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ScenarioEntity } from './scenario.entity'; import { ScenarioStepEntity } from './scenario-step.entity'; +import { ScenarioRunEntity } from './scenario-run.entity'; +import { ScenarioRunStepEntity } from './scenario-run-step.entity'; import { CreateScenarioDto } from './dto/create-scenario.dto'; import { UpdateScenarioDto } from './dto/update-scenario.dto'; import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; @@ -23,6 +25,10 @@ export class ScenarioService { private readonly scenarioRepo: Repository, @InjectRepository(ScenarioStepEntity) private readonly stepRepo: Repository, + @InjectRepository(ScenarioRunEntity) + private readonly runRepo: Repository, + @InjectRepository(ScenarioRunStepEntity) + private readonly runStepRepo: Repository, ) {} // ── Scenarios ───────────────────────────────────────────────────────────── @@ -46,7 +52,7 @@ export class ScenarioService { const scenario = await this.scenarioRepo.findOne({ where: { id }, relations: ['steps'], - order: { steps: { id: 'ASC' } }, + order: { steps: { order: 'ASC' } }, }); if (!scenario) throw new NotFoundException(`Scenario ${id} not found`); return scenario; @@ -93,4 +99,33 @@ export class ScenarioService { await this.findStep(scenarioId, stepId); await this.stepRepo.delete(stepId); } + + // ── Runs ────────────────────────────────────────────────────────────────── + + async createRun(scenarioId: number): Promise { + const scenario = await this.findOne(scenarioId); + + const run = await this.runRepo.save( + this.runRepo.create({ scenarioId, status: 'pending' }), + ); + + const stepRuns = scenario.steps.map((step, index) => + this.runStepRepo.create({ + runId: run.id, + scenarioStepId: step.id, + order: step.order, + status: index === 0 ? 'pending' : 'waiting', + description: null, + }), + ); + + await this.runStepRepo.save(stepRuns); + + return this.runRepo.findOne({ + where: { id: run.id }, + relations: ['stepRuns'], + order: { stepRuns: { order: 'ASC' } }, + }) as Promise; + } } +