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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user