diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 182276c..6488a23 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -192,10 +192,10 @@ export const scenarios = { remove(id: string): Promise { return request(`/scenarios/${id}`, { method: 'DELETE' }); }, - run(id: string, environmentId: string): Promise { + run(id: string, environmentId: string, saveSession?: boolean): Promise { return request(`/scenarios/${id}/run`, { method: 'POST', - body: JSON.stringify({ environmentId }), + body: JSON.stringify({ environmentId, saveSession }), }); }, getRun(scenarioId: string, runId: string): Promise { diff --git a/client/src/api/types.ts b/client/src/api/types.ts index e6ed21f..c2a1700 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -98,6 +98,7 @@ export interface ScenarioRun { id: string; scenarioId: string; environmentId: string; + saveSession: boolean; scenario?: Pick; status: ScenarioRunStatus; stepRuns?: ScenarioRunStep[]; diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index fa392ff..b767d1e 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -44,6 +44,7 @@ export function ScenarioDetailPage() { const [reorderingSteps, setReorderingSteps] = useState(false); const [runModalOpen, setRunModalOpen] = useState(false); const [running, setRunning] = useState(false); + const [saveSessionFlag, setSaveSessionFlag] = useState(false); const [envs, setEnvs] = useState([]); const [selectedEnvId, setSelectedEnvId] = useState(''); const [pendingDelete, setPendingDelete] = useState< @@ -89,10 +90,11 @@ export function ScenarioDetailPage() { } setRunning(true); try { - const run = await scenarios.run(scenario.id, selectedEnvId); + const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag); toast.success('Scenario run started'); navigate(`/scenarios/${id}/runs/${run.id}`); setRunModalOpen(false); + setSaveSessionFlag(false); } catch (err) { toast.error((err as Error).message); } finally { @@ -545,12 +547,23 @@ export function ScenarioDetailPage() { {envs.length === 0 ? (

{t('scenarios.run_modal_no_env')}

) : ( - setSelectedEnvId(e.target.value)} + options={envs.map((env) => ({ value: env.id, label: env.name }))} + /> + + )} diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index f9084fa..5590f5b 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -703,11 +703,12 @@ export class McpService { inputSchema: { id: z.uuid().describe("Scenario ID to run"), 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 { - const run = await this.scenarioService.createRun(id, environmentId); + const run = await this.scenarioService.createRun(id, environmentId, saveSession); return { content: [{ type: "text" as const, text: JSON.stringify(run) }], }; diff --git a/server/src/scenario/dto/create-scenario-run.dto.ts b/server/src/scenario/dto/create-scenario-run.dto.ts index d0e091c..a0402f0 100644 --- a/server/src/scenario/dto/create-scenario-run.dto.ts +++ b/server/src/scenario/dto/create-scenario-run.dto.ts @@ -1,7 +1,11 @@ -import { IsNotEmpty, IsUUID } from "class-validator"; +import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from "class-validator"; export class CreateScenarioRunDto { @IsUUID() @IsNotEmpty() environmentId: string; + + @IsBoolean() + @IsOptional() + saveSession?: boolean; } diff --git a/server/src/scenario/scenario-run.entity.ts b/server/src/scenario/scenario-run.entity.ts index 8e69629..aee0259 100644 --- a/server/src/scenario/scenario-run.entity.ts +++ b/server/src/scenario/scenario-run.entity.ts @@ -31,6 +31,9 @@ export class ScenarioRunEntity { @Column({ type: "text", default: "pending" }) status: RunStatus; + @Column({ type: "boolean", default: false }) + saveSession: boolean; + @OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, { cascade: true, eager: false, diff --git a/server/src/scenario/scenario-scheduler.service.ts b/server/src/scenario/scenario-scheduler.service.ts index 43fd51e..c687c45 100644 --- a/server/src/scenario/scenario-scheduler.service.ts +++ b/server/src/scenario/scenario-scheduler.service.ts @@ -10,6 +10,8 @@ import { CodeExecutorService } from "../code-executor/code-executor.service"; import { traceStorage } from "../common/trace-context"; import { TraceLogger } from "../common/trace-logger"; 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 { ScenarioRunLogEntity } from "./scenario-run-log.entity"; import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; @@ -47,6 +49,8 @@ export class ScenarioSchedulerService { private readonly codeExecutor: CodeExecutorService, private readonly scenarioService: ScenarioService, private readonly snippetService: SnippetService, + private readonly sessionService: SessionService, + private readonly sessionContextService: SessionContextService, ) {} private persistLog( @@ -150,6 +154,53 @@ export class ScenarioSchedulerService { this.runCredentials.delete(runId); this.runSnippets.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 { + 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 => ({})); + 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); } } diff --git a/server/src/scenario/scenario.controller.ts b/server/src/scenario/scenario.controller.ts index ca1d485..8e7b479 100644 --- a/server/src/scenario/scenario.controller.ts +++ b/server/src/scenario/scenario.controller.ts @@ -214,7 +214,7 @@ export class ScenarioController { @Param("id", ParseUUIDPipe) id: string, @Body() dto: CreateScenarioRunDto, ) { - return this.scenarioService.createRun(id, dto.environmentId); + return this.scenarioService.createRun(id, dto.environmentId, dto.saveSession); } @Get(":id/run/:runId") diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 2ed19c3..e7275eb 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -359,6 +359,7 @@ export class ScenarioService { async createRun( scenarioId: string, environmentId: string, + saveSession = false, ): Promise { const scenario = await this.findOne(scenarioId); const environment = await this.environmentRepo.findOneBy({ @@ -369,7 +370,7 @@ export class ScenarioService { } 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) =>