feat: add saveSession flag to create scenario runs

- Add optional `saveSession` boolean parameter to CreateScenarioRunDto
- Add `saveSession` column to ScenarioRunEntity
- When saveSession=true and run completes, preserve the browser context as a named explore session instead of closing it
- Session name follows pattern: run-{runId}
- Automatically extracts token and localStorage for session persistence
- Injected SessionService and SessionContextService into ScenarioSchedulerService
- Updated MCP run_scenario tool to accept saveSession parameter
- Updated UI run dialog with checkbox to enable session preservation
- Client API updated to pass saveSession flag
This commit is contained in:
2026-04-14 18:54:45 +03:00
parent 0e47623a6b
commit 196bf4c2e8
9 changed files with 88 additions and 14 deletions
+2 -2
View File
@@ -192,10 +192,10 @@ export const scenarios = {
remove(id: string): Promise<void> { remove(id: string): Promise<void> {
return request(`/scenarios/${id}`, { method: 'DELETE' }); return request(`/scenarios/${id}`, { method: 'DELETE' });
}, },
run(id: string, environmentId: string): Promise<ScenarioRun> { run(id: string, environmentId: string, saveSession?: boolean): Promise<ScenarioRun> {
return request(`/scenarios/${id}/run`, { return request(`/scenarios/${id}/run`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ environmentId }), body: JSON.stringify({ environmentId, saveSession }),
}); });
}, },
getRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> { getRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
+1
View File
@@ -98,6 +98,7 @@ export interface ScenarioRun {
id: string; id: string;
scenarioId: string; scenarioId: string;
environmentId: string; environmentId: string;
saveSession: boolean;
scenario?: Pick<Scenario, 'id' | 'name'>; scenario?: Pick<Scenario, 'id' | 'name'>;
status: ScenarioRunStatus; status: ScenarioRunStatus;
stepRuns?: ScenarioRunStep[]; stepRuns?: ScenarioRunStep[];
@@ -44,6 +44,7 @@ export function ScenarioDetailPage() {
const [reorderingSteps, setReorderingSteps] = useState(false); const [reorderingSteps, setReorderingSteps] = useState(false);
const [runModalOpen, setRunModalOpen] = useState(false); const [runModalOpen, setRunModalOpen] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [saveSessionFlag, setSaveSessionFlag] = useState(false);
const [envs, setEnvs] = useState<Environment[]>([]); const [envs, setEnvs] = useState<Environment[]>([]);
const [selectedEnvId, setSelectedEnvId] = useState(''); const [selectedEnvId, setSelectedEnvId] = useState('');
const [pendingDelete, setPendingDelete] = useState< const [pendingDelete, setPendingDelete] = useState<
@@ -89,10 +90,11 @@ export function ScenarioDetailPage() {
} }
setRunning(true); setRunning(true);
try { try {
const run = await scenarios.run(scenario.id, selectedEnvId); const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag);
toast.success('Scenario run started'); toast.success('Scenario run started');
navigate(`/scenarios/${id}/runs/${run.id}`); navigate(`/scenarios/${id}/runs/${run.id}`);
setRunModalOpen(false); setRunModalOpen(false);
setSaveSessionFlag(false);
} catch (err) { } catch (err) {
toast.error((err as Error).message); toast.error((err as Error).message);
} finally { } finally {
@@ -545,12 +547,23 @@ export function ScenarioDetailPage() {
{envs.length === 0 ? ( {envs.length === 0 ? (
<p>{t('scenarios.run_modal_no_env')}</p> <p>{t('scenarios.run_modal_no_env')}</p>
) : ( ) : (
<Select <>
label={t('scenarios.run_modal_env_label')} <Select
value={selectedEnvId} label={t('scenarios.run_modal_env_label')}
onChange={(e) => setSelectedEnvId(e.target.value)} value={selectedEnvId}
options={envs.map((env) => ({ value: env.id, label: env.name }))} onChange={(e) => setSelectedEnvId(e.target.value)}
/> options={envs.map((env) => ({ value: env.id, label: env.name }))}
/>
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}>
<input
type="checkbox"
checked={saveSessionFlag}
onChange={(e) => setSaveSessionFlag(e.target.checked)}
disabled={running}
/>
<span>Save session for explore mode</span>
</label>
</>
)} )}
</Modal> </Modal>
</div> </div>
+3 -2
View File
@@ -703,11 +703,12 @@ export class McpService {
inputSchema: { inputSchema: {
id: z.uuid().describe("Scenario ID to run"), id: z.uuid().describe("Scenario ID to run"),
environmentId: z.uuid().describe("Environment ID to run the scenario in"), environmentId: z.uuid().describe("Environment ID to run the scenario in"),
saveSession: z.boolean().optional().describe("Save session for explore mode after run completes"),
}, },
}, },
async ({ id, environmentId }) => { async ({ id, environmentId, saveSession }) => {
try { try {
const run = await this.scenarioService.createRun(id, environmentId); const run = await this.scenarioService.createRun(id, environmentId, saveSession);
return { return {
content: [{ type: "text" as const, text: JSON.stringify(run) }], content: [{ type: "text" as const, text: JSON.stringify(run) }],
}; };
@@ -1,7 +1,11 @@
import { IsNotEmpty, IsUUID } from "class-validator"; import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from "class-validator";
export class CreateScenarioRunDto { export class CreateScenarioRunDto {
@IsUUID() @IsUUID()
@IsNotEmpty() @IsNotEmpty()
environmentId: string; environmentId: string;
@IsBoolean()
@IsOptional()
saveSession?: boolean;
} }
@@ -31,6 +31,9 @@ export class ScenarioRunEntity {
@Column({ type: "text", default: "pending" }) @Column({ type: "text", default: "pending" })
status: RunStatus; status: RunStatus;
@Column({ type: "boolean", default: false })
saveSession: boolean;
@OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, { @OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, {
cascade: true, cascade: true,
eager: false, eager: false,
@@ -10,6 +10,8 @@ import { CodeExecutorService } from "../code-executor/code-executor.service";
import { traceStorage } from "../common/trace-context"; import { traceStorage } from "../common/trace-context";
import { TraceLogger } from "../common/trace-logger"; import { TraceLogger } from "../common/trace-logger";
import { EnvironmentData, EnvironmentEntity } from "../environment/environment.entity"; import { EnvironmentData, EnvironmentEntity } from "../environment/environment.entity";
import { SessionContextService } from "../session/session-context.service";
import { SessionService } from "../session/session.service";
import { SnippetService } from "../snippet/snippet.service"; import { SnippetService } from "../snippet/snippet.service";
import { ScenarioRunLogEntity } from "./scenario-run-log.entity"; import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
@@ -47,6 +49,8 @@ export class ScenarioSchedulerService {
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
private readonly scenarioService: ScenarioService, private readonly scenarioService: ScenarioService,
private readonly snippetService: SnippetService, private readonly snippetService: SnippetService,
private readonly sessionService: SessionService,
private readonly sessionContextService: SessionContextService,
) {} ) {}
private persistLog( private persistLog(
@@ -150,6 +154,53 @@ 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);
await this.maybePreserveSession(runId);
}
}
/**
* If the run was created with saveSession=true, promote the run's browser
* context to a named explore session instead of closing it.
*/
private async maybePreserveSession(runId: string): Promise<void> {
const run = await this.runRepo.findOneBy({ id: runId });
if (!run?.saveSession) {
await this.closeBrowserHandle(runId);
return;
}
const handle = this.runBrowsers.get(runId);
if (!handle) return;
const sessionName = `run-${runId}`;
try {
const cookies = await handle.context.cookies();
const localStorage = await handle.page
.evaluate(() => ({ ...window.localStorage }))
.catch((): Record<string, string> => ({}));
const token =
(localStorage["accessToken"] as string | undefined) ??
(localStorage["token"] as string | undefined) ??
cookies.find((c) => c.name === "token")?.value ??
"";
await this.sessionService.upsert(sessionName, token, cookies, localStorage);
this.sessionContextService.register(
sessionName,
handle.browser,
handle.context,
handle.page,
);
// Remove from runBrowsers so closeBrowserHandle won't close it
this.runBrowsers.delete(runId);
this.logger.log(
`Run #${runId}: browser preserved as session "${sessionName}"`,
);
} catch (err) {
this.logger.warn(
`Run #${runId}: failed to preserve session — ${(err as Error).message}`,
);
await this.closeBrowserHandle(runId);
} }
} }
+1 -1
View File
@@ -214,7 +214,7 @@ export class ScenarioController {
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreateScenarioRunDto, @Body() dto: CreateScenarioRunDto,
) { ) {
return this.scenarioService.createRun(id, dto.environmentId); return this.scenarioService.createRun(id, dto.environmentId, dto.saveSession);
} }
@Get(":id/run/:runId") @Get(":id/run/:runId")
+2 -1
View File
@@ -359,6 +359,7 @@ export class ScenarioService {
async createRun( async createRun(
scenarioId: string, scenarioId: string,
environmentId: string, environmentId: string,
saveSession = false,
): Promise<ScenarioRunEntity> { ): Promise<ScenarioRunEntity> {
const scenario = await this.findOne(scenarioId); const scenario = await this.findOne(scenarioId);
const environment = await this.environmentRepo.findOneBy({ const environment = await this.environmentRepo.findOneBy({
@@ -369,7 +370,7 @@ export class ScenarioService {
} }
const run = await this.runRepo.save( const run = await this.runRepo.save(
this.runRepo.create({ scenarioId, environmentId, status: "pending" }), this.runRepo.create({ scenarioId, environmentId, status: "pending", saveSession }),
); );
const stepRuns = scenario.steps.map((step, index) => const stepRuns = scenario.steps.map((step, index) =>