feat(scenario): add scenario runs, run steps, and step ordering

- 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
This commit is contained in:
2026-04-07 14:27:17 +03:00
parent d669bf1c59
commit caf120741d
9 changed files with 158 additions and 5 deletions
+3 -1
View File
@@ -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<string>('DB_PATH', 'data/sessions.db'),
entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity],
entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity],
synchronize: true,
}),
}),
+6 -1
View File
@@ -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;
+7 -1
View File
@@ -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'])
+48
View File
@@ -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;
}
+42
View File
@@ -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;
}
+3
View File
@@ -25,6 +25,9 @@ export class ScenarioStepEntity {
@JoinColumn({ name: 'scenarioId' })
scenario: ScenarioEntity;
@Column({ default: 0 })
order: number;
@Column({ type: 'text' })
type: StepType;
+10
View File
@@ -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);
}
}
+3 -1
View File
@@ -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],
+36 -1
View File
@@ -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<ScenarioEntity>,
@InjectRepository(ScenarioStepEntity)
private readonly stepRepo: Repository<ScenarioStepEntity>,
@InjectRepository(ScenarioRunEntity)
private readonly runRepo: Repository<ScenarioRunEntity>,
@InjectRepository(ScenarioRunStepEntity)
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
) {}
// ── 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<ScenarioRunEntity> {
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<ScenarioRunEntity>;
}
}