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:
+3
-1
@@ -10,6 +10,8 @@ import { EnvironmentModule } from './environment/environment.module';
|
|||||||
import { McpModule } from './mcp/mcp.module';
|
import { McpModule } from './mcp/mcp.module';
|
||||||
import { ScenarioEntity } from './scenario/scenario.entity';
|
import { ScenarioEntity } from './scenario/scenario.entity';
|
||||||
import { ScenarioStepEntity } from './scenario/scenario-step.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';
|
import { ScenarioModule } from './scenario/scenario.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -23,7 +25,7 @@ import { ScenarioModule } from './scenario/scenario.module';
|
|||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
type: 'better-sqlite3',
|
type: 'better-sqlite3',
|
||||||
database: config.get<string>('DB_PATH', 'data/sessions.db'),
|
database: config.get<string>('DB_PATH', 'data/sessions.db'),
|
||||||
entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity],
|
entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity],
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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';
|
import { StepType } from '../scenario-step.entity';
|
||||||
|
|
||||||
export class CreateScenarioStepDto {
|
export class CreateScenarioStepDto {
|
||||||
|
@ApiProperty({ example: 0, description: 'Execution order (ascending)' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
order: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['login', 'exec'], example: 'exec' })
|
@ApiProperty({ enum: ['login', 'exec'], example: 'exec' })
|
||||||
@IsIn(['login', 'exec'])
|
@IsIn(['login', 'exec'])
|
||||||
type: StepType;
|
type: StepType;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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';
|
import { StepType } from '../scenario-step.entity';
|
||||||
|
|
||||||
export class UpdateScenarioStepDto {
|
export class UpdateScenarioStepDto {
|
||||||
|
@ApiPropertyOptional({ example: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
order?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['login', 'exec'] })
|
@ApiPropertyOptional({ enum: ['login', 'exec'] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['login', 'exec'])
|
@IsIn(['login', 'exec'])
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ export class ScenarioStepEntity {
|
|||||||
@JoinColumn({ name: 'scenarioId' })
|
@JoinColumn({ name: 'scenarioId' })
|
||||||
scenario: ScenarioEntity;
|
scenario: ScenarioEntity;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
order: number;
|
||||||
|
|
||||||
@Column({ type: 'text' })
|
@Column({ type: 'text' })
|
||||||
type: StepType;
|
type: StepType;
|
||||||
|
|
||||||
|
|||||||
@@ -111,4 +111,14 @@ export class ScenarioController {
|
|||||||
) {
|
) {
|
||||||
return this.scenarioService.removeStep(id, stepId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ScenarioEntity } from './scenario.entity';
|
import { ScenarioEntity } from './scenario.entity';
|
||||||
import { ScenarioStepEntity } from './scenario-step.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 { ScenarioService } from './scenario.service';
|
||||||
import { ScenarioController } from './scenario.controller';
|
import { ScenarioController } from './scenario.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity])],
|
imports: [TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity])],
|
||||||
controllers: [ScenarioController],
|
controllers: [ScenarioController],
|
||||||
providers: [ScenarioService],
|
providers: [ScenarioService],
|
||||||
exports: [ScenarioService],
|
exports: [ScenarioService],
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ScenarioEntity } from './scenario.entity';
|
import { ScenarioEntity } from './scenario.entity';
|
||||||
import { ScenarioStepEntity } from './scenario-step.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 { CreateScenarioDto } from './dto/create-scenario.dto';
|
||||||
import { UpdateScenarioDto } from './dto/update-scenario.dto';
|
import { UpdateScenarioDto } from './dto/update-scenario.dto';
|
||||||
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
||||||
@@ -23,6 +25,10 @@ export class ScenarioService {
|
|||||||
private readonly scenarioRepo: Repository<ScenarioEntity>,
|
private readonly scenarioRepo: Repository<ScenarioEntity>,
|
||||||
@InjectRepository(ScenarioStepEntity)
|
@InjectRepository(ScenarioStepEntity)
|
||||||
private readonly stepRepo: Repository<ScenarioStepEntity>,
|
private readonly stepRepo: Repository<ScenarioStepEntity>,
|
||||||
|
@InjectRepository(ScenarioRunEntity)
|
||||||
|
private readonly runRepo: Repository<ScenarioRunEntity>,
|
||||||
|
@InjectRepository(ScenarioRunStepEntity)
|
||||||
|
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||||
@@ -46,7 +52,7 @@ export class ScenarioService {
|
|||||||
const scenario = await this.scenarioRepo.findOne({
|
const scenario = await this.scenarioRepo.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['steps'],
|
relations: ['steps'],
|
||||||
order: { steps: { id: 'ASC' } },
|
order: { steps: { order: 'ASC' } },
|
||||||
});
|
});
|
||||||
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
|
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
|
||||||
return scenario;
|
return scenario;
|
||||||
@@ -93,4 +99,33 @@ export class ScenarioService {
|
|||||||
await this.findStep(scenarioId, stepId);
|
await this.findStep(scenarioId, stepId);
|
||||||
await this.stepRepo.delete(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>;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user