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