feat(scenarios): add timeoutSeconds to scenarios and steps

- add timeoutSeconds field to scenario and step entities
- add timeoutSeconds to create/update DTOs for scenario and step
- enforce timeout via scheduler: abort run if step exceeds limit
- expose timeoutSeconds in MCP create/update scenario and step tools
- add client-side type, API, i18n, and form support for timeoutSeconds
- add integration tests for timeout persistence via REST and MCP
This commit is contained in:
2026-04-17 13:46:30 +03:00
parent 8dbd319bba
commit a50dce2755
19 changed files with 402 additions and 12 deletions
+30 -2
View File
@@ -476,14 +476,21 @@ export class McpService {
.uuid()
.optional()
.describe("Optional linked environment ID"),
timeoutSeconds: z
.number()
.int()
.min(1)
.optional()
.describe("Scenario-level timeout in seconds (default 600)"),
},
},
async ({ name, description, environmentId }) => {
async ({ name, description, environmentId, timeoutSeconds }) => {
try {
const scenario = await this.scenarioService.create({
name,
description,
environmentId,
timeoutSeconds,
});
return {
content: [
@@ -515,14 +522,22 @@ export class McpService {
.nullable()
.optional()
.describe("Linked environment ID (null to unlink)"),
timeoutSeconds: z
.number()
.int()
.min(1)
.nullable()
.optional()
.describe("Scenario-level timeout in seconds (null to reset to default 600)"),
},
},
async ({ id, name, description, environmentId }) => {
async ({ id, name, description, environmentId, timeoutSeconds }) => {
try {
const scenario = await this.scenarioService.update(id, {
name,
description,
environmentId,
timeoutSeconds,
});
return {
content: [
@@ -580,6 +595,12 @@ export class McpService {
.string()
.optional()
.describe("Playwright JS code to execute (exec steps)"),
timeoutSeconds: z
.number()
.int()
.min(1)
.optional()
.describe("Step-level timeout in seconds (default 60, falls back to scenario timeout)"),
},
},
async ({ scenarioId, ...dto }) => {
@@ -640,6 +661,13 @@ export class McpService {
.describe("New step type"),
title: z.string().optional().describe("New step title"),
execCode: z.string().optional().describe("New exec code"),
timeoutSeconds: z
.number()
.int()
.min(1)
.nullable()
.optional()
.describe("Step-level timeout in seconds (null to reset to default)"),
},
},
async ({ scenarioId, stepId, ...dto }) => {
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
export class CreateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" })
@@ -12,4 +12,10 @@ export class CreateScenarioStepDto {
@IsString()
@IsNotEmpty()
execCode?: string;
@ApiPropertyOptional({ description: "Step timeout in seconds (default: 60)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number;
}
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator";
export class CreateScenarioDto {
@ApiProperty({ example: "Login and verify cabinet" })
@@ -16,4 +16,10 @@ export class CreateScenarioDto {
@IsOptional()
@IsUUID()
environmentId?: string;
@ApiPropertyOptional({ description: "Scenario timeout in seconds (default: 600)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number;
}
@@ -18,4 +18,10 @@ export class UpdateScenarioStepDto {
@IsString()
@IsNotEmpty()
execCode?: string;
@ApiPropertyOptional({ description: "Step timeout in seconds (null to use default of 60)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number | null;
}
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator";
export class UpdateScenarioDto {
@ApiPropertyOptional({ example: "Updated scenario name" })
@@ -17,4 +17,10 @@ export class UpdateScenarioDto {
@IsOptional()
@IsUUID()
environmentId?: string | null;
@ApiPropertyOptional({ description: "Scenario timeout in seconds (null to use default of 600)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number | null;
}
@@ -40,6 +40,11 @@ export class ScenarioSchedulerService {
private readonly runSnippets = new Map<string, Record<string, string>>();
// Cache environment values per run (built once when a run starts)
private readonly runEnvironments = new Map<string, EnvironmentData>();
// Cache scenario-level timeout (seconds) per run
private readonly runScenarioTimeouts = new Map<string, number | null>();
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
constructor(
@InjectRepository(ScenarioRunEntity)
@@ -120,6 +125,11 @@ export class ScenarioSchedulerService {
.then((env) => env?.data ?? {})
.catch(() => ({}) as EnvironmentData);
this.runEnvironments.set(run.id, environmentData);
// Cache scenario-level timeout for the run
const scenario = await this.scenarioService
.findOne(run.scenarioId)
.catch(() => null);
this.runScenarioTimeouts.set(run.id, scenario?.timeoutSeconds ?? null);
const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () =>
this.processRunToCompletion(run.id),
@@ -128,6 +138,10 @@ export class ScenarioSchedulerService {
}
private async processRunToCompletion(runId: string): Promise<void> {
const scenarioTimeoutSec =
this.runScenarioTimeouts.get(runId) ??
ScenarioSchedulerService.DEFAULT_SCENARIO_TIMEOUT_SEC;
const scenarioDeadline = Date.now() + scenarioTimeoutSec * 1000;
try {
let stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" },
@@ -135,6 +149,28 @@ export class ScenarioSchedulerService {
order: { order: "ASC" },
});
while (stepRun) {
if (Date.now() >= scenarioDeadline) {
this.logger.error(
`Run #${runId}: exceeded scenario timeout of ${scenarioTimeoutSec}s`,
);
this.persistLog(
runId,
null,
"error",
`Scenario timed out after ${scenarioTimeoutSec}s`,
);
await this.runRepo.update(runId, { status: "fail" });
await this.runStepRepo
.createQueryBuilder()
.update()
.set({ status: "cancelled" })
.where("runId = :runId AND status IN (:...statuses)", {
runId,
statuses: ["waiting", "pending", "in_progress"],
})
.execute();
return;
}
await this.executeStepRun(stepRun);
stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" },
@@ -158,6 +194,7 @@ export class ScenarioSchedulerService {
this.runCredentials.delete(runId);
this.runSnippets.delete(runId);
this.runEnvironments.delete(runId);
this.runScenarioTimeouts.delete(runId);
await this.maybePreserveSession(runId);
}
}
@@ -245,7 +282,24 @@ export class ScenarioSchedulerService {
.environment(env)
.snippets(snips)
.build();
const { result: execOutput } = await this.codeExecutor.execute(execCtx);
const scenarioTimeoutSec = this.runScenarioTimeouts.get(stepRun.runId) ?? null;
const stepTimeoutSec =
step.timeoutSeconds ??
scenarioTimeoutSec ??
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
const stepTimeoutMs = stepTimeoutSec * 1000;
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
stepTimeoutMs,
),
);
const { result: execOutput } = await Promise.race([
this.codeExecutor.execute(execCtx),
timeoutPromise,
]);
await this.passStepRun(stepRun, null, execOutput);
} catch (err) {
@@ -32,6 +32,9 @@ export class ScenarioStepEntity {
@Column({ type: "text", nullable: true })
execCode: string | null;
@Column({ type: "int", nullable: true })
timeoutSeconds: number | null;
@CreateDateColumn()
createdAt: Date;
+3
View File
@@ -26,6 +26,9 @@ export class ScenarioEntity {
@Column({ nullable: true, type: "text" })
environmentId: string | null;
@Column({ type: "int", nullable: true })
timeoutSeconds: number | null;
@ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false })
@JoinColumn({ name: "environmentId" })
environment: EnvironmentEntity | null;