feat(scenarios): add step title, scenario credentials, and environment context in executor

- add nullable title column to scenario steps; exposed in create/edit forms and step table
- add scenario-credential join (many-to-many with CredentialEntity) with CRUD endpoints
- expose environment URLs in code executor helpers via helpers.env and helpers.getEnvUrl()
- resolve and cache environment per run from the login step's environmentName in scheduler
- add section spacing and step table title column to ScenarioDetailPage
This commit is contained in:
2026-04-09 23:37:21 +03:00
parent 1f3a604940
commit 1efbbb38a3
34 changed files with 1362 additions and 14 deletions
+6
View File
@@ -9,12 +9,15 @@ import { BrowserModule } from "./browser/browser.module";
import { SessionEntity } from "./session/session.entity";
import { EnvironmentEntity } from "./environment/environment.entity";
import { EnvironmentModule } from "./environment/environment.module";
import { CredentialEntity } from "./credential/credential.entity";
import { CredentialModule } from "./credential/credential.module";
import { McpModule } from "./mcp/mcp.module";
import { ScenarioEntity } from "./scenario/scenario.entity";
import { ScenarioStepEntity } from "./scenario/scenario-step.entity";
import { ScenarioRunEntity } from "./scenario/scenario-run.entity";
import { ScenarioRunStepEntity } from "./scenario/scenario-run-step.entity";
import { ScenarioRunLogEntity } from "./scenario/scenario-run-log.entity";
import { ScenarioCredentialEntity } from "./scenario/scenario-credential.entity";
import { ScenarioModule } from "./scenario/scenario.module";
@Module({
@@ -33,11 +36,13 @@ import { ScenarioModule } from "./scenario/scenario.module";
entities: [
SessionEntity,
EnvironmentEntity,
CredentialEntity,
ScenarioEntity,
ScenarioStepEntity,
ScenarioRunEntity,
ScenarioRunStepEntity,
ScenarioRunLogEntity,
ScenarioCredentialEntity,
],
synchronize: true,
}),
@@ -45,6 +50,7 @@ import { ScenarioModule } from "./scenario/scenario.module";
AuthModule,
BrowserModule,
EnvironmentModule,
CredentialModule,
ScenarioModule,
McpModule,
HealthModule,
@@ -7,6 +7,7 @@ import { TraceLogger } from "../common/trace-logger";
import { parse } from "acorn";
import type { Page, BrowserContext } from "playwright";
import { dumpDom } from "./dom-helpers";
import type { EnvironmentUrls } from "../environment/environment.entity";
export interface ExecResult {
result: unknown;
@@ -47,6 +48,8 @@ export class CodeExecutorService {
code: string,
log?: ScriptLogger,
getStepOutput?: (order: number) => Promise<unknown>,
credentials?: Record<string, unknown>,
environment?: EnvironmentUrls | null,
): Promise<ExecResult> {
const scriptLog: ScriptLogger =
log ?? ((level, msg) => this.logger[level](msg));
@@ -55,6 +58,9 @@ export class CodeExecutorService {
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
.join(" ");
const credMap: Record<string, unknown> = credentials ?? {};
const envUrls: EnvironmentUrls = environment ?? {};
try {
const pageHelpers = {
dumpDom: (selector?: string) => dumpDom(page, selector),
@@ -62,6 +68,26 @@ export class CodeExecutorService {
warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
error: (...args: unknown[]) => scriptLog("error", toStr(args)),
getStepOutput: getStepOutput ?? (() => Promise.resolve(null)),
getCredential: (alias: string): unknown => {
if (!(alias in credMap)) {
throw new Error(
`Credential alias "${alias}" not found in this scenario`,
);
}
return credMap[alias];
},
/** All URLs defined for the current environment (may be empty if no environment is set). */
env: { ...envUrls },
/** Returns the URL for the given key, or throws if it is not defined. */
getEnvUrl: (key: string): string => {
const value = envUrls[key];
if (value == null) {
throw new Error(
`Environment URL "${key}" is not defined for this environment`,
);
}
return value;
},
};
const fakeConsole = {
@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { CredentialService } from "./credential.service";
import { CreateCredentialDto } from "./dto/create-credential.dto";
import { UpdateCredentialDto } from "./dto/update-credential.dto";
import { PaginationQueryDto } from "../common/dto/pagination.dto";
import { CredentialOrderBy } from "./credential.service";
@ApiTags("credentials")
@Controller("credentials")
export class CredentialController {
constructor(private readonly credentialService: CredentialService) {}
@Post()
@ApiOperation({ summary: "Create a new credential" })
@ApiResponse({ status: 201, description: "Credential created" })
create(@Body() dto: CreateCredentialDto) {
return this.credentialService.create(dto);
}
@Get()
@ApiOperation({ summary: "List all credentials (paginated)" })
@ApiResponse({ status: 200, description: "Paginated credentials" })
findAll(@Query() query: PaginationQueryDto<CredentialOrderBy>) {
return this.credentialService.findAll(query);
}
@Get(":id")
@ApiOperation({ summary: "Get credential by ID" })
@ApiResponse({ status: 200, description: "Credential record" })
@ApiResponse({ status: 404, description: "Credential not found" })
findOne(@Param("id", ParseIntPipe) id: number) {
return this.credentialService.findOne(id);
}
@Patch(":id")
@ApiOperation({ summary: "Update a credential" })
@ApiResponse({ status: 200, description: "Credential updated" })
@ApiResponse({ status: 404, description: "Credential not found" })
update(
@Param("id", ParseIntPipe) id: number,
@Body() dto: UpdateCredentialDto,
) {
return this.credentialService.update(id, dto);
}
@Delete(":id")
@HttpCode(204)
@ApiOperation({ summary: "Delete a credential" })
@ApiResponse({ status: 204, description: "Credential deleted" })
@ApiResponse({ status: 404, description: "Credential not found" })
remove(@Param("id", ParseIntPipe) id: number) {
return this.credentialService.remove(id);
}
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("credentials")
export class CredentialEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column("text", { nullable: true })
data: string | null;
@Column({ type: "datetime", nullable: true })
lastUsedAt: Date | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CredentialEntity } from "./credential.entity";
import { CredentialService } from "./credential.service";
import { CredentialController } from "./credential.controller";
@Module({
imports: [TypeOrmModule.forFeature([CredentialEntity])],
controllers: [CredentialController],
providers: [CredentialService],
exports: [CredentialService],
})
export class CredentialModule {}
@@ -0,0 +1,56 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { CredentialEntity } from "./credential.entity";
import { CreateCredentialDto } from "./dto/create-credential.dto";
import { UpdateCredentialDto } from "./dto/update-credential.dto";
import {
PaginationQueryDto,
PaginatedResult,
} from "../common/dto/pagination.dto";
export type CredentialOrderBy = "id" | "name" | "lastUsedAt" | "createdAt" | "updatedAt";
@Injectable()
export class CredentialService {
constructor(
@InjectRepository(CredentialEntity)
private readonly repo: Repository<CredentialEntity>,
) {}
async create(dto: CreateCredentialDto): Promise<CredentialEntity> {
return this.repo.save(this.repo.create({ ...dto, data: dto.data ?? null }));
}
async findAll(
query: PaginationQueryDto<CredentialOrderBy> = {},
): Promise<PaginatedResult<CredentialEntity>> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? "id";
const orderDir = query.orderDir ?? "ASC";
const [data, total] = await this.repo.findAndCount({
order: { [orderBy]: orderDir },
skip: (page - 1) * limit,
take: limit,
});
return { data, total, page, limit };
}
async findOne(id: number): Promise<CredentialEntity> {
const credential = await this.repo.findOneBy({ id });
if (!credential) throw new NotFoundException(`Credential ${id} not found`);
return credential;
}
async update(id: number, dto: UpdateCredentialDto): Promise<CredentialEntity> {
const credential = await this.findOne(id);
Object.assign(credential, dto);
return this.repo.save(credential);
}
async remove(id: number): Promise<void> {
await this.findOne(id);
await this.repo.delete(id);
}
}
@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateCredentialDto {
@ApiProperty({ example: "my-api-key" })
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({
description: "JSON-encoded credential payload",
example: '{"token":"abc123","secret":"xyz"}',
})
@IsOptional()
@IsString()
data?: string;
}
@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateCredentialDto } from "./create-credential.dto";
export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsInt, IsNotEmpty, IsPositive, IsString } from "class-validator";
export class AddScenarioCredentialDto {
@ApiProperty({ example: 1 })
@IsInt()
@IsPositive()
credentialId: number;
@ApiProperty({ example: "api_key" })
@IsString()
@IsNotEmpty()
alias: string;
}
@@ -10,6 +10,11 @@ import {
import { StepType } from "../scenario-step.entity";
export class CreateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" })
@IsOptional()
@IsString()
title?: string;
@ApiProperty({ example: 0, description: "Execution order (ascending)" })
@IsInt()
@Min(0)
@@ -10,6 +10,11 @@ import {
import { StepType } from "../scenario-step.entity";
export class UpdateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ example: 0 })
@IsOptional()
@IsInt()
@@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Unique,
} from "typeorm";
import { ScenarioEntity } from "./scenario.entity";
import { CredentialEntity } from "../credential/credential.entity";
@Entity("scenario_credentials")
@Unique(["scenarioId", "alias"])
export class ScenarioCredentialEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
scenarioId: number;
@Column()
credentialId: number;
@Column()
alias: string;
@ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" })
@JoinColumn({ name: "scenarioId" })
scenario: ScenarioEntity;
@ManyToOne(() => CredentialEntity, { onDelete: "CASCADE", eager: true })
@JoinColumn({ name: "credentialId" })
credential: CredentialEntity;
@CreateDateColumn()
createdAt: Date;
}
@@ -15,6 +15,9 @@ import type { ScriptLogger } from "../code-executor/code-executor.service";
import { AuthService } from "../auth/auth.service";
import { CodeExecutorService } from "../code-executor/code-executor.service";
import { SessionService } from "../session/session.service";
import { ScenarioService } from "./scenario.service";
import { EnvironmentService } from "../environment/environment.service";
import type { EnvironmentUrls } from "../environment/environment.entity";
interface ValidateResult {
success: boolean;
@@ -32,6 +35,10 @@ export class ScenarioSchedulerService {
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
private readonly activeRuns = new Set<number>();
private readonly runBrowsers = new Map<number, BrowserHandle>();
// Cache credential maps per run (built once when a run starts)
private readonly runCredentials = new Map<number, Record<string, unknown>>();
// Cache environment URLs per run (resolved from the first login step)
private readonly runEnvironments = new Map<number, EnvironmentUrls>();
constructor(
@InjectRepository(ScenarioRunEntity)
@@ -43,6 +50,8 @@ export class ScenarioSchedulerService {
private readonly authService: AuthService,
private readonly codeExecutor: CodeExecutorService,
private readonly sessionService: SessionService,
private readonly scenarioService: ScenarioService,
private readonly environmentService: EnvironmentService,
) {}
private persistLog(
@@ -81,6 +90,30 @@ export class ScenarioSchedulerService {
};
}
/**
* Finds the first login step for a scenario and returns the environment URLs
* for the environment named in that step's execCode. Returns null if there is
* no login step or the environment cannot be found.
*/
private async resolveRunEnvironment(
scenarioId: number,
): Promise<EnvironmentUrls | null> {
try {
const scenario = await this.scenarioService.findOne(scenarioId);
const loginStep = scenario.steps.find((s) => s.type === "login");
if (!loginStep?.execCode) return null;
const params = JSON.parse(loginStep.execCode) as {
environmentName?: string;
};
if (!params.environmentName) return null;
const { data } = await this.environmentService.findAll({});
const env = data.find((e) => e.name === params.environmentName);
return env?.urls ?? null;
} catch {
return null;
}
}
// ── Job: pick up pending runs and process each to completion ─────────────
@Interval(1000)
@@ -92,6 +125,14 @@ export class ScenarioSchedulerService {
run.status = "in_progress";
await this.runRepo.save(run);
this.logger.log(`Run #${run.id} → in_progress`);
// Pre-load credential map for the scenario
const credMap = await this.scenarioService
.buildCredentialMap(run.scenarioId)
.catch(() => ({} as Record<string, unknown>));
this.runCredentials.set(run.id, credMap);
// Resolve environment from the first login step (best-effort)
const envUrls = await this.resolveRunEnvironment(run.scenarioId);
if (envUrls) this.runEnvironments.set(run.id, envUrls);
const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () =>
this.processRunToCompletion(run.id),
@@ -114,8 +155,6 @@ export class ScenarioSchedulerService {
order: { order: "ASC" },
});
}
// Guard: if there were no steps (or all steps already resolved via passStepRun),
// ensure the run is not left in in_progress.
await this.runRepo
.createQueryBuilder()
.update()
@@ -129,6 +168,8 @@ export class ScenarioSchedulerService {
await this.runRepo.update(runId, { status: "fail" });
} finally {
this.activeRuns.delete(runId);
this.runCredentials.delete(runId);
this.runEnvironments.delete(runId);
}
}
@@ -250,6 +291,8 @@ export class ScenarioSchedulerService {
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
this.runCredentials.get(stepRun.runId),
this.runEnvironments.get(stepRun.runId),
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
@@ -272,12 +315,16 @@ export class ScenarioSchedulerService {
step.sessionName,
);
const getStepOutput = this.makeGetStepOutput(stepRun);
const creds = this.runCredentials.get(stepRun.runId);
const env = this.runEnvironments.get(stepRun.runId);
const { result: execOutput } = await this.codeExecutor.execute(
page,
context,
step.execCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
);
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
@@ -289,6 +336,8 @@ export class ScenarioSchedulerService {
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
@@ -327,6 +376,8 @@ export class ScenarioSchedulerService {
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
this.runCredentials.get(stepRun.runId),
this.runEnvironments.get(stepRun.runId),
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
@@ -31,6 +31,9 @@ export class ScenarioStepEntity {
@Column({ type: "text" })
type: StepType;
@Column({ type: "text", nullable: true })
title: string | null;
@Column({ type: "text" })
sessionName: string;
@@ -16,6 +16,7 @@ import { CreateScenarioDto } from "./dto/create-scenario.dto";
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
import { PaginationQueryDto } from "./dto/pagination-query.dto";
import { ScenarioOrderBy } from "./scenario.service";
import { RunsQueryDto } from "./dto/runs-query.dto";
@@ -140,6 +141,40 @@ export class ScenarioController {
return this.scenarioService.removeStep(id, stepId);
}
// ── Credentials ───────────────────────────────────────────────────────────
@Get(":id/credentials")
@ApiOperation({ summary: "List credentials assigned to a scenario" })
@ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario not found" })
findScenarioCredentials(@Param("id", ParseIntPipe) id: number) {
return this.scenarioService.findScenarioCredentials(id);
}
@Post(":id/credentials")
@ApiOperation({ summary: "Add a credential to a scenario with an alias" })
@ApiResponse({ status: 201, description: "Credential added" })
@ApiResponse({ status: 404, description: "Scenario or credential not found" })
@ApiResponse({ status: 409, description: "Alias already used in this scenario" })
addScenarioCredential(
@Param("id", ParseIntPipe) id: number,
@Body() dto: AddScenarioCredentialDto,
) {
return this.scenarioService.addScenarioCredential(id, dto);
}
@Delete(":id/credentials/:scCredId")
@HttpCode(204)
@ApiOperation({ summary: "Remove a credential from a scenario" })
@ApiResponse({ status: 204 })
@ApiResponse({ status: 404, description: "Scenario or credential assignment not found" })
removeScenarioCredential(
@Param("id", ParseIntPipe) id: number,
@Param("scCredId", ParseIntPipe) scCredId: number,
) {
return this.scenarioService.removeScenarioCredential(id, scCredId);
}
// ── Runs ──────────────────────────────────────────────────────────────────
@Get(":id/runs")
+7
View File
@@ -7,6 +7,7 @@ import {
OneToMany,
} from "typeorm";
import { ScenarioStepEntity } from "./scenario-step.entity";
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
@Entity("scenarios")
export class ScenarioEntity {
@@ -22,6 +23,12 @@ export class ScenarioEntity {
})
steps: ScenarioStepEntity[];
@OneToMany(() => ScenarioCredentialEntity, (sc) => sc.scenario, {
cascade: true,
eager: false,
})
scenarioCredentials: ScenarioCredentialEntity[];
@CreateDateColumn()
createdAt: Date;
+6
View File
@@ -5,12 +5,15 @@ import { ScenarioStepEntity } from "./scenario-step.entity";
import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
import { CredentialEntity } from "../credential/credential.entity";
import { ScenarioService } from "./scenario.service";
import { ScenarioController } from "./scenario.controller";
import { ScenarioSchedulerService } from "./scenario-scheduler.service";
import { AuthModule } from "../auth/auth.module";
import { CodeExecutorModule } from "../code-executor/code-executor.module";
import { SessionModule } from "../session/session.module";
import { EnvironmentModule } from "../environment/environment.module";
@Module({
imports: [
@@ -20,10 +23,13 @@ import { SessionModule } from "../session/session.module";
ScenarioRunEntity,
ScenarioRunStepEntity,
ScenarioRunLogEntity,
ScenarioCredentialEntity,
CredentialEntity,
]),
AuthModule,
CodeExecutorModule,
SessionModule,
EnvironmentModule,
],
controllers: [ScenarioController],
providers: [ScenarioService, ScenarioSchedulerService],
+87 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ScenarioEntity } from "./scenario.entity";
@@ -6,10 +6,13 @@ import { ScenarioStepEntity } from "./scenario-step.entity";
import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
import { CredentialEntity } from "../credential/credential.entity";
import { CreateScenarioDto } from "./dto/create-scenario.dto";
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
import {
PaginationQueryDto,
PaginatedResult,
@@ -33,6 +36,10 @@ export class ScenarioService {
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
@InjectRepository(ScenarioRunLogEntity)
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
@InjectRepository(ScenarioCredentialEntity)
private readonly scenarioCredRepo: Repository<ScenarioCredentialEntity>,
@InjectRepository(CredentialEntity)
private readonly credentialRepo: Repository<CredentialEntity>,
) {}
// ── Scenarios ─────────────────────────────────────────────────────────────
@@ -59,7 +66,7 @@ export class ScenarioService {
async findOne(id: number): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.findOne({
where: { id },
relations: ["steps"],
relations: ["steps", "scenarioCredentials", "scenarioCredentials.credential"],
order: { steps: { order: "ASC" } },
});
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
@@ -121,6 +128,84 @@ export class ScenarioService {
await this.stepRepo.delete(stepId);
}
// ── Scenario Credentials ──────────────────────────────────────────────────
async findScenarioCredentials(
scenarioId: number,
): Promise<ScenarioCredentialEntity[]> {
await this.findOne(scenarioId); // 404 guard
return this.scenarioCredRepo.find({
where: { scenarioId },
relations: ["credential"],
order: { alias: "ASC" },
});
}
async addScenarioCredential(
scenarioId: number,
dto: AddScenarioCredentialDto,
): Promise<ScenarioCredentialEntity> {
await this.findOne(scenarioId); // 404 guard
const credential = await this.credentialRepo.findOneBy({
id: dto.credentialId,
});
if (!credential)
throw new NotFoundException(`Credential ${dto.credentialId} not found`);
const existing = await this.scenarioCredRepo.findOneBy({
scenarioId,
alias: dto.alias,
});
if (existing)
throw new ConflictException(
`Alias "${dto.alias}" already used in this scenario`,
);
const sc = this.scenarioCredRepo.create({
scenarioId,
credentialId: dto.credentialId,
alias: dto.alias,
});
return this.scenarioCredRepo.save(sc);
}
async removeScenarioCredential(
scenarioId: number,
scCredId: number,
): Promise<void> {
await this.findOne(scenarioId); // 404 guard
const sc = await this.scenarioCredRepo.findOneBy({
id: scCredId,
scenarioId,
});
if (!sc)
throw new NotFoundException(
`Scenario credential ${scCredId} not found in scenario ${scenarioId}`,
);
await this.scenarioCredRepo.delete(scCredId);
}
/**
* Builds a map of alias → parsed credential data for use in code execution.
* Returns null values for credentials with no data.
*/
async buildCredentialMap(scenarioId: number): Promise<Record<string, unknown>> {
const scs = await this.findScenarioCredentials(scenarioId);
const map: Record<string, unknown> = {};
for (const sc of scs) {
let parsed: unknown = null;
if (sc.credential.data) {
try {
parsed = JSON.parse(sc.credential.data);
} catch {
parsed = sc.credential.data;
}
}
map[sc.alias] = parsed;
}
return map;
}
// ── Runs ──────────────────────────────────────────────────────────────────
async findRuns(
scenarioId: number,
query: RunsQueryDto,