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
@@ -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) {