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:
@@ -13,3 +13,11 @@ client/dist
|
|||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# organization
|
||||||
|
TODO.md
|
||||||
|
|
||||||
|
# IDE/editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.github/
|
||||||
@@ -193,7 +193,7 @@ export const scenarios = {
|
|||||||
body: JSON.stringify({ name, description, environmentId }),
|
body: JSON.stringify({ name, description, environmentId }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId'>>): Promise<Scenario> {
|
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): Promise<Scenario> {
|
||||||
return request(`/scenarios/${id}`, {
|
return request(`/scenarios/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
@@ -254,7 +254,7 @@ export const runs = {
|
|||||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||||
): Promise<
|
): Promise<
|
||||||
PaginatedResponse<
|
PaginatedResponse<
|
||||||
ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }
|
ScenarioRun & { scenario: { id: string; name: string } }
|
||||||
>
|
>
|
||||||
> {
|
> {
|
||||||
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
|
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
|
||||||
@@ -299,12 +299,14 @@ export const scenarioCredentials = {
|
|||||||
export interface CreateStepPayload {
|
export interface CreateStepPayload {
|
||||||
title?: string;
|
title?: string;
|
||||||
execCode?: string;
|
execCode?: string;
|
||||||
|
timeoutSeconds?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStepPayload {
|
export interface UpdateStepPayload {
|
||||||
title?: string;
|
title?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
execCode?: string;
|
execCode?: string;
|
||||||
|
timeoutSeconds?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const steps = {
|
export const steps = {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface ScenarioStep {
|
|||||||
order: number;
|
order: number;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
execCode: string | null;
|
execCode: string | null;
|
||||||
|
timeoutSeconds: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,7 @@ export interface Scenario {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
environmentId?: string | null;
|
environmentId?: string | null;
|
||||||
|
timeoutSeconds?: number | null;
|
||||||
environment?: Pick<Environment, 'id' | 'name'>;
|
environment?: Pick<Environment, 'id' | 'name'>;
|
||||||
steps?: ScenarioStep[];
|
steps?: ScenarioStep[];
|
||||||
scenarioCredentials?: ScenarioCredential[];
|
scenarioCredentials?: ScenarioCredential[];
|
||||||
|
|||||||
@@ -185,6 +185,9 @@
|
|||||||
"export_modal_title": "Export Scenario",
|
"export_modal_title": "Export Scenario",
|
||||||
"export_include_environment": "Include Environment",
|
"export_include_environment": "Include Environment",
|
||||||
"export_include_credentials": "Include Credentials",
|
"export_include_credentials": "Include Credentials",
|
||||||
|
"form_timeout": "Scenario Timeout (seconds)",
|
||||||
|
"form_timeout_placeholder": "Default: 600",
|
||||||
|
"form_timeout_hint": "Max seconds for the entire scenario run (default: 600)",
|
||||||
"field_name": "Name",
|
"field_name": "Name",
|
||||||
"field_id": "ID"
|
"field_id": "ID"
|
||||||
},
|
},
|
||||||
@@ -222,7 +225,10 @@
|
|||||||
"form_session_placeholder": "e.g. my-session",
|
"form_session_placeholder": "e.g. my-session",
|
||||||
"form_session_required": "Session name is required",
|
"form_session_required": "Session name is required",
|
||||||
"form_exec_code": "Exec code",
|
"form_exec_code": "Exec code",
|
||||||
"form_exec_code_placeholder": "return await page.title();"
|
"form_exec_code_placeholder": "return await page.title();",
|
||||||
|
"form_timeout": "Step Timeout (seconds)",
|
||||||
|
"form_timeout_placeholder": "Default: 60",
|
||||||
|
"form_timeout_hint": "Max seconds for this step (default: 60)"
|
||||||
},
|
},
|
||||||
"runs": {
|
"runs": {
|
||||||
"title": "Runs",
|
"title": "Runs",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AllRunRow = ScenarioRun & {
|
type AllRunRow = ScenarioRun & {
|
||||||
scenario: { id: number; name: string };
|
scenario: { id: string; name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AllRunsPage() {
|
export function AllRunsPage() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function CreateStepPage() {
|
|||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
|
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,6 +34,7 @@ export function CreateStepPage() {
|
|||||||
await steps.create(id!, {
|
await steps.create(id!, {
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
execCode: execCode.trim() || undefined,
|
execCode: execCode.trim() || undefined,
|
||||||
|
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : undefined,
|
||||||
});
|
});
|
||||||
toast.success(t('steps.created'));
|
toast.success(t('steps.created'));
|
||||||
navigate(`/scenarios/${id}`);
|
navigate(`/scenarios/${id}`);
|
||||||
@@ -76,6 +78,15 @@ export function CreateStepPage() {
|
|||||||
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
||||||
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
|
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('steps.form_timeout')}
|
||||||
|
placeholder={t('steps.form_timeout_placeholder')}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={timeoutSeconds}
|
||||||
|
onChange={(e) => setTimeoutSeconds(e.target.value)}
|
||||||
|
hint={t('steps.form_timeout_hint')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function EditScenarioPage() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [environmentId, setEnvironmentId] = useState<string>('');
|
const [environmentId, setEnvironmentId] = useState<string>('');
|
||||||
|
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||||
const [envs, setEnvs] = useState<Environment[]>([]);
|
const [envs, setEnvs] = useState<Environment[]>([]);
|
||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,6 +32,7 @@ export function EditScenarioPage() {
|
|||||||
setName(data.name);
|
setName(data.name);
|
||||||
setDescription(data.description || '');
|
setDescription(data.description || '');
|
||||||
setEnvironmentId(data.environmentId ?? '');
|
setEnvironmentId(data.environmentId ?? '');
|
||||||
|
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
||||||
@@ -48,6 +50,7 @@ export function EditScenarioPage() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
environmentId: environmentId || null,
|
environmentId: environmentId || null,
|
||||||
|
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null,
|
||||||
});
|
});
|
||||||
toast.success(t('scenarios.updated'));
|
toast.success(t('scenarios.updated'));
|
||||||
navigate(`/scenarios/${id}`);
|
navigate(`/scenarios/${id}`);
|
||||||
@@ -114,6 +117,15 @@ export function EditScenarioPage() {
|
|||||||
...envs.map((env) => ({ value: env.id, label: env.name })),
|
...envs.map((env) => ({ value: env.id, label: env.name })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('scenarios.form_timeout')}
|
||||||
|
placeholder={t('scenarios.form_timeout_placeholder')}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={timeoutSeconds}
|
||||||
|
onChange={(e) => setTimeoutSeconds(e.target.value)}
|
||||||
|
hint={t('scenarios.form_timeout_hint')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function EditStepPage() {
|
|||||||
const [step, setStep] = useState<ScenarioStep | null>(null);
|
const [step, setStep] = useState<ScenarioStep | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
|
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export function EditStepPage() {
|
|||||||
setStep(st);
|
setStep(st);
|
||||||
setTitle(st.title ?? '');
|
setTitle(st.title ?? '');
|
||||||
setExecCode(st.execCode ?? '');
|
setExecCode(st.execCode ?? '');
|
||||||
|
setTimeoutSeconds(st.timeoutSeconds != null ? String(st.timeoutSeconds) : '');
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id, stepId]);
|
}, [id, stepId]);
|
||||||
@@ -39,6 +41,7 @@ export function EditStepPage() {
|
|||||||
await steps.update(id!, stepId!, {
|
await steps.update(id!, stepId!, {
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
execCode: execCode.trim() || undefined,
|
execCode: execCode.trim() || undefined,
|
||||||
|
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null,
|
||||||
});
|
});
|
||||||
toast.success(t('steps.updated'));
|
toast.success(t('steps.updated'));
|
||||||
navigate(`/scenarios/${id}`);
|
navigate(`/scenarios/${id}`);
|
||||||
@@ -64,7 +67,7 @@ export function EditStepPage() {
|
|||||||
label: scenario?.name ?? `#${id}`,
|
label: scenario?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
},
|
},
|
||||||
{ label: t('steps.edit_title', { order: step ? step.order + 1 : stepId }) },
|
{ label: t('steps.edit_title', { order: step ? step.order : stepId }) },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,6 +88,15 @@ export function EditStepPage() {
|
|||||||
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
||||||
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
|
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('steps.form_timeout')}
|
||||||
|
placeholder={t('steps.form_timeout_placeholder')}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={timeoutSeconds}
|
||||||
|
onChange={(e) => setTimeoutSeconds(e.target.value)}
|
||||||
|
hint={t('steps.form_timeout_hint')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export function ScenarioDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order + 1, width: 60 },
|
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: t('scenarios.step_title'),
|
header: t('scenarios.step_title'),
|
||||||
|
|||||||
@@ -476,14 +476,21 @@ export class McpService {
|
|||||||
.uuid()
|
.uuid()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Optional linked environment ID"),
|
.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 {
|
try {
|
||||||
const scenario = await this.scenarioService.create({
|
const scenario = await this.scenarioService.create({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
environmentId,
|
environmentId,
|
||||||
|
timeoutSeconds,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -515,14 +522,22 @@ export class McpService {
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Linked environment ID (null to unlink)"),
|
.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 {
|
try {
|
||||||
const scenario = await this.scenarioService.update(id, {
|
const scenario = await this.scenarioService.update(id, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
environmentId,
|
environmentId,
|
||||||
|
timeoutSeconds,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -580,6 +595,12 @@ export class McpService {
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Playwright JS code to execute (exec steps)"),
|
.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 }) => {
|
async ({ scenarioId, ...dto }) => {
|
||||||
@@ -640,6 +661,13 @@ export class McpService {
|
|||||||
.describe("New step type"),
|
.describe("New step type"),
|
||||||
title: z.string().optional().describe("New step title"),
|
title: z.string().optional().describe("New step title"),
|
||||||
execCode: z.string().optional().describe("New exec code"),
|
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 }) => {
|
async ({ scenarioId, stepId, ...dto }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
|
||||||
|
|
||||||
export class CreateScenarioStepDto {
|
export class CreateScenarioStepDto {
|
||||||
@ApiPropertyOptional({ example: "Check login page title" })
|
@ApiPropertyOptional({ example: "Check login page title" })
|
||||||
@@ -12,4 +12,10 @@ export class CreateScenarioStepDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
execCode?: string;
|
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 { 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 {
|
export class CreateScenarioDto {
|
||||||
@ApiProperty({ example: "Login and verify cabinet" })
|
@ApiProperty({ example: "Login and verify cabinet" })
|
||||||
@@ -16,4 +16,10 @@ export class CreateScenarioDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
environmentId?: string;
|
environmentId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Scenario timeout in seconds (default: 600)" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
timeoutSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,10 @@ export class UpdateScenarioStepDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
execCode?: string;
|
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 { 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 {
|
export class UpdateScenarioDto {
|
||||||
@ApiPropertyOptional({ example: "Updated scenario name" })
|
@ApiPropertyOptional({ example: "Updated scenario name" })
|
||||||
@@ -17,4 +17,10 @@ export class UpdateScenarioDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
environmentId?: string | null;
|
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>>();
|
private readonly runSnippets = new Map<string, Record<string, string>>();
|
||||||
// Cache environment values per run (built once when a run starts)
|
// Cache environment values per run (built once when a run starts)
|
||||||
private readonly runEnvironments = new Map<string, EnvironmentData>();
|
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(
|
constructor(
|
||||||
@InjectRepository(ScenarioRunEntity)
|
@InjectRepository(ScenarioRunEntity)
|
||||||
@@ -120,6 +125,11 @@ export class ScenarioSchedulerService {
|
|||||||
.then((env) => env?.data ?? {})
|
.then((env) => env?.data ?? {})
|
||||||
.catch(() => ({}) as EnvironmentData);
|
.catch(() => ({}) as EnvironmentData);
|
||||||
this.runEnvironments.set(run.id, 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();
|
const traceId = crypto.randomUUID();
|
||||||
void traceStorage.run({ traceId }, () =>
|
void traceStorage.run({ traceId }, () =>
|
||||||
this.processRunToCompletion(run.id),
|
this.processRunToCompletion(run.id),
|
||||||
@@ -128,6 +138,10 @@ export class ScenarioSchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async processRunToCompletion(runId: string): Promise<void> {
|
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 {
|
try {
|
||||||
let stepRun = await this.runStepRepo.findOne({
|
let stepRun = await this.runStepRepo.findOne({
|
||||||
where: { runId, status: "pending" },
|
where: { runId, status: "pending" },
|
||||||
@@ -135,6 +149,28 @@ export class ScenarioSchedulerService {
|
|||||||
order: { order: "ASC" },
|
order: { order: "ASC" },
|
||||||
});
|
});
|
||||||
while (stepRun) {
|
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);
|
await this.executeStepRun(stepRun);
|
||||||
stepRun = await this.runStepRepo.findOne({
|
stepRun = await this.runStepRepo.findOne({
|
||||||
where: { runId, status: "pending" },
|
where: { runId, status: "pending" },
|
||||||
@@ -158,6 +194,7 @@ export class ScenarioSchedulerService {
|
|||||||
this.runCredentials.delete(runId);
|
this.runCredentials.delete(runId);
|
||||||
this.runSnippets.delete(runId);
|
this.runSnippets.delete(runId);
|
||||||
this.runEnvironments.delete(runId);
|
this.runEnvironments.delete(runId);
|
||||||
|
this.runScenarioTimeouts.delete(runId);
|
||||||
await this.maybePreserveSession(runId);
|
await this.maybePreserveSession(runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +282,24 @@ export class ScenarioSchedulerService {
|
|||||||
.environment(env)
|
.environment(env)
|
||||||
.snippets(snips)
|
.snippets(snips)
|
||||||
.build();
|
.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);
|
await this.passStepRun(stepRun, null, execOutput);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export class ScenarioStepEntity {
|
|||||||
@Column({ type: "text", nullable: true })
|
@Column({ type: "text", nullable: true })
|
||||||
execCode: string | null;
|
execCode: string | null;
|
||||||
|
|
||||||
|
@Column({ type: "int", nullable: true })
|
||||||
|
timeoutSeconds: number | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export class ScenarioEntity {
|
|||||||
@Column({ nullable: true, type: "text" })
|
@Column({ nullable: true, type: "text" })
|
||||||
environmentId: string | null;
|
environmentId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: "int", nullable: true })
|
||||||
|
timeoutSeconds: number | null;
|
||||||
|
|
||||||
@ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false })
|
@ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false })
|
||||||
@JoinColumn({ name: "environmentId" })
|
@JoinColumn({ name: "environmentId" })
|
||||||
environment: EnvironmentEntity | null;
|
environment: EnvironmentEntity | null;
|
||||||
|
|||||||
@@ -141,4 +141,147 @@ describe("McpController", () => {
|
|||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── create_scenario / update_scenario timeoutSeconds ──────────────────────
|
||||||
|
|
||||||
|
describe("create_scenario with timeoutSeconds", () => {
|
||||||
|
it("saves timeoutSeconds on the created scenario", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("create_scenario", {
|
||||||
|
name: "mcp-timeout-sc",
|
||||||
|
timeoutSeconds: 300,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const created = JSON.parse(result.content[0].text) as {
|
||||||
|
name: string;
|
||||||
|
timeoutSeconds: number | null;
|
||||||
|
};
|
||||||
|
expect(created.name).toBe("mcp-timeout-sc");
|
||||||
|
expect(created.timeoutSeconds).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null timeoutSeconds when not provided", async () => {
|
||||||
|
const { rpc } = await mcpCall("create_scenario", {
|
||||||
|
name: "mcp-no-timeout",
|
||||||
|
});
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const created = JSON.parse(result.content[0].text) as {
|
||||||
|
timeoutSeconds: number | null;
|
||||||
|
};
|
||||||
|
expect(created.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update_scenario timeoutSeconds", () => {
|
||||||
|
it("updates and clears timeoutSeconds", async () => {
|
||||||
|
// create
|
||||||
|
const createRpc = (
|
||||||
|
await mcpCall("create_scenario", { name: "mcp-upd-timeout" })
|
||||||
|
).rpc;
|
||||||
|
const created = JSON.parse(
|
||||||
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
// set
|
||||||
|
const setRpc = (
|
||||||
|
await mcpCall("update_scenario", {
|
||||||
|
id: created.id,
|
||||||
|
timeoutSeconds: 120,
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const updated = JSON.parse(
|
||||||
|
(setRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { timeoutSeconds: number | null };
|
||||||
|
expect(updated.timeoutSeconds).toBe(120);
|
||||||
|
|
||||||
|
// clear
|
||||||
|
const clearRpc = (
|
||||||
|
await mcpCall("update_scenario", {
|
||||||
|
id: created.id,
|
||||||
|
timeoutSeconds: null,
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const cleared = JSON.parse(
|
||||||
|
(clearRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { timeoutSeconds: number | null };
|
||||||
|
expect(cleared.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── create_scenario_step / update_scenario_step timeoutSeconds ────────────
|
||||||
|
|
||||||
|
describe("create_scenario_step with timeoutSeconds", () => {
|
||||||
|
it("saves timeoutSeconds on the created step", async () => {
|
||||||
|
// create a scenario first
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", { name: "mcp-step-timeout-parent" })
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("create_scenario_step", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
order: 0,
|
||||||
|
type: "exec",
|
||||||
|
execCode: "return 1;",
|
||||||
|
timeoutSeconds: 45,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const step = JSON.parse(result.content[0].text) as {
|
||||||
|
timeoutSeconds: number | null;
|
||||||
|
};
|
||||||
|
expect(step.timeoutSeconds).toBe(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update_scenario_step timeoutSeconds", () => {
|
||||||
|
it("updates and clears step timeoutSeconds", async () => {
|
||||||
|
// create scenario + step
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", { name: "mcp-step-upd-parent" })
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const stepRpc = (
|
||||||
|
await mcpCall("create_scenario_step", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
order: 0,
|
||||||
|
type: "exec",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const step = JSON.parse(
|
||||||
|
(stepRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
// set
|
||||||
|
const setRpc = (
|
||||||
|
await mcpCall("update_scenario_step", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
stepId: step.id,
|
||||||
|
timeoutSeconds: 90,
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const updated = JSON.parse(
|
||||||
|
(setRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { timeoutSeconds: number | null };
|
||||||
|
expect(updated.timeoutSeconds).toBe(90);
|
||||||
|
|
||||||
|
// clear
|
||||||
|
const clearRpc = (
|
||||||
|
await mcpCall("update_scenario_step", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
stepId: step.id,
|
||||||
|
timeoutSeconds: null,
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const cleared = JSON.parse(
|
||||||
|
(clearRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { timeoutSeconds: number | null };
|
||||||
|
expect(cleared.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ describe("ScenarioController", () => {
|
|||||||
.send({})
|
.send({})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves timeoutSeconds when provided", async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post("/scenarios")
|
||||||
|
.send({ name: "timeout-sc", timeoutSeconds: 300 })
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.timeoutSeconds).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null timeoutSeconds when not provided", async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post("/scenarios")
|
||||||
|
.send({ name: "no-timeout-sc" })
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for timeoutSeconds below 1", async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/scenarios")
|
||||||
|
.send({ name: "bad-timeout", timeoutSeconds: 0 })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /scenarios ─────────────────────────────────────────────────────────
|
// ── GET /scenarios ─────────────────────────────────────────────────────────
|
||||||
@@ -181,6 +204,27 @@ describe("ScenarioController", () => {
|
|||||||
expect(res.body.name).toBe("patched");
|
expect(res.body.name).toBe("patched");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates timeoutSeconds", async () => {
|
||||||
|
const sc = await createScenario("timeout-patch");
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.patch(`/scenarios/${sc.id}`)
|
||||||
|
.send({ timeoutSeconds: 120 })
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.timeoutSeconds).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears timeoutSeconds to null", async () => {
|
||||||
|
const created = await request(app.getHttpServer())
|
||||||
|
.post("/scenarios")
|
||||||
|
.send({ name: "clear-timeout", timeoutSeconds: 120 })
|
||||||
|
.expect(201);
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.patch(`/scenarios/${created.body.id}`)
|
||||||
|
.send({ timeoutSeconds: null })
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch("/scenarios/00000000-0000-0000-0000-000000000001")
|
.patch("/scenarios/00000000-0000-0000-0000-000000000001")
|
||||||
@@ -260,6 +304,24 @@ describe("ScenarioController", () => {
|
|||||||
.expect(201);
|
.expect(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves timeoutSeconds on step when provided", async () => {
|
||||||
|
const sc = await createScenario();
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
|
.send({ execCode: "return 1;", timeoutSeconds: 45 })
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.timeoutSeconds).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null step timeoutSeconds when not provided", async () => {
|
||||||
|
const sc = await createScenario();
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
|
.send({ execCode: "return 1;" })
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post("/scenarios/00000000-0000-0000-0000-000000000001/steps")
|
.post("/scenarios/00000000-0000-0000-0000-000000000001/steps")
|
||||||
@@ -320,6 +382,26 @@ describe("ScenarioController", () => {
|
|||||||
expect(res.body.execCode).toBe("return 99;");
|
expect(res.body.execCode).toBe("return 99;");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates step timeoutSeconds", async () => {
|
||||||
|
const sc = await createScenario();
|
||||||
|
const step = await createStep(sc.id);
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.patch(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||||
|
.send({ timeoutSeconds: 90 })
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.timeoutSeconds).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears step timeoutSeconds to null", async () => {
|
||||||
|
const sc = await createScenario();
|
||||||
|
const step = await createStep(sc.id, { timeoutSeconds: 90 });
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.patch(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||||
|
.send({ timeoutSeconds: null })
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.timeoutSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown step", async () => {
|
it("returns 404 for unknown step", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
|
|||||||
Reference in New Issue
Block a user