- Add seq column assigned in call order to fix logs racing each other as fire-and-forget writes (e.g. a snippet's "started" entry could be saved after its "finished" entry) - Log snippet start/finish/failure via context.runSnippet, matching existing step start/pass/fail logging - Rework Markdown log export to per-entry sections instead of a table - Version bump: 1.10.0 -> 1.10.1
963 lines
30 KiB
TypeScript
963 lines
30 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { InjectRepository } from "@nestjs/typeorm";
|
|
import * as fs from "fs/promises";
|
|
import * as path from "path";
|
|
import { Like, Repository } from "typeorm";
|
|
import {
|
|
PaginatedResult,
|
|
PaginationQueryDto,
|
|
} from "../common/dto/pagination.dto";
|
|
import { CredentialEntity } from "../credential/credential.entity";
|
|
import { EnvironmentEntity } from "../environment/environment.entity";
|
|
import { FileStorageService } from "../file/file-storage.service";
|
|
import { ScenarioFileEntity } from "../file/scenario-file.entity";
|
|
import { ScenarioRunFileEntity } from "../file/scenario-run-file.entity";
|
|
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
|
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
|
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
|
import { RunsQueryDto } from "./dto/runs-query.dto";
|
|
import {
|
|
CredentialExportDto,
|
|
EnvironmentExportInlineDto,
|
|
ExportEntity,
|
|
ScenarioExportDto,
|
|
} from "./dto/scenario-export.dto";
|
|
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
|
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
|
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
|
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
|
|
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
|
import { ScenarioRunEntity } from "./scenario-run.entity";
|
|
import { ScenarioStepEntity } from "./scenario-step.entity";
|
|
import { ScenarioEntity } from "./scenario.entity";
|
|
import { Response } from "express";
|
|
import type { File as MulterFile } from "multer";
|
|
|
|
export { PaginatedResult } from "../common/dto/pagination.dto";
|
|
export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
|
|
|
@Injectable()
|
|
export class ScenarioService {
|
|
constructor(
|
|
@InjectRepository(ScenarioEntity)
|
|
private readonly scenarioRepo: Repository<ScenarioEntity>,
|
|
@InjectRepository(ScenarioStepEntity)
|
|
private readonly stepRepo: Repository<ScenarioStepEntity>,
|
|
@InjectRepository(ScenarioRunEntity)
|
|
private readonly runRepo: Repository<ScenarioRunEntity>,
|
|
@InjectRepository(ScenarioRunStepEntity)
|
|
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>,
|
|
@InjectRepository(EnvironmentEntity)
|
|
private readonly environmentRepo: Repository<EnvironmentEntity>,
|
|
@InjectRepository(ScenarioFileEntity)
|
|
private readonly scenarioFileRepo: Repository<ScenarioFileEntity>,
|
|
@InjectRepository(ScenarioRunFileEntity)
|
|
private readonly scenarioRunFileRepo: Repository<ScenarioRunFileEntity>,
|
|
private readonly fileStorageService: FileStorageService,
|
|
) {}
|
|
|
|
// ── Scenarios ─────────────────────────────────────────────────────────────
|
|
|
|
create(dto: CreateScenarioDto): Promise<ScenarioEntity> {
|
|
return this.scenarioRepo.save(this.scenarioRepo.create(dto));
|
|
}
|
|
|
|
async findAll(
|
|
query: PaginationQueryDto<ScenarioOrderBy>,
|
|
): Promise<PaginatedResult<ScenarioEntity & { lastRunStatus: string | null; lastRunAt: string | null }>> {
|
|
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.scenarioRepo.findAndCount({
|
|
order: { [orderBy]: orderDir },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
});
|
|
|
|
let lastRunByScenario = new Map<string, { status: string; createdAt: Date }>();
|
|
if (data.length > 0) {
|
|
const ids = data.map((s) => s.id);
|
|
const latestRuns = await this.runRepo
|
|
.createQueryBuilder("r")
|
|
.select(["r.scenarioId", "r.status", "r.createdAt"])
|
|
.where("r.scenarioId IN (:...ids)", { ids })
|
|
.andWhere((qb) => {
|
|
const sub = qb
|
|
.subQuery()
|
|
.select("MAX(r2.createdAt)")
|
|
.from(ScenarioRunEntity, "r2")
|
|
.where("r2.scenarioId = r.scenarioId")
|
|
.getQuery();
|
|
return `r.createdAt = (${sub})`;
|
|
})
|
|
.getMany();
|
|
lastRunByScenario = new Map(
|
|
latestRuns.map((r) => [r.scenarioId, { status: r.status, createdAt: r.createdAt }]),
|
|
);
|
|
}
|
|
|
|
return {
|
|
data: data.map((s) => ({
|
|
...s,
|
|
lastRunStatus: lastRunByScenario.get(s.id)?.status ?? null,
|
|
lastRunAt: lastRunByScenario.get(s.id)?.createdAt?.toISOString() ?? null,
|
|
})),
|
|
total,
|
|
page,
|
|
limit,
|
|
};
|
|
}
|
|
|
|
async findOne(id: string): Promise<ScenarioEntity> {
|
|
const scenario = await this.scenarioRepo.findOne({
|
|
where: { id },
|
|
relations: [
|
|
"steps",
|
|
"scenarioCredentials",
|
|
"scenarioCredentials.credential",
|
|
"environment",
|
|
],
|
|
order: { steps: { order: "ASC" } },
|
|
});
|
|
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
|
|
return scenario;
|
|
}
|
|
|
|
async update(id: string, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
|
|
const scenario = await this.findOne(id);
|
|
Object.assign(scenario, dto);
|
|
return this.scenarioRepo.save(scenario);
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
await this.findOne(id);
|
|
await this.scenarioRepo.delete(id);
|
|
}
|
|
|
|
// ── Steps ─────────────────────────────────────────────────────────────────
|
|
|
|
private async normalizeStepOrder(scenarioId: string): Promise<void> {
|
|
const ordered = await this.stepRepo.find({
|
|
where: { scenarioId },
|
|
order: { order: "ASC", createdAt: "ASC", id: "ASC" },
|
|
});
|
|
for (let i = 0; i < ordered.length; i += 1) {
|
|
const step = ordered[i];
|
|
if (step.order !== i + 1) {
|
|
step.order = i + 1;
|
|
await this.stepRepo.save(step);
|
|
}
|
|
}
|
|
}
|
|
|
|
async createStep(
|
|
scenarioId: string,
|
|
dto: CreateScenarioStepDto,
|
|
): Promise<ScenarioStepEntity> {
|
|
await this.findOne(scenarioId);
|
|
const currentCount = await this.stepRepo.count({ where: { scenarioId } });
|
|
const created = await this.stepRepo.save(
|
|
this.stepRepo.create({
|
|
...dto,
|
|
order: currentCount + 1,
|
|
scenarioId,
|
|
title: dto.title ?? null,
|
|
execCode: dto.execCode ?? null,
|
|
}),
|
|
);
|
|
await this.normalizeStepOrder(scenarioId);
|
|
return this.findStep(scenarioId, created.id);
|
|
}
|
|
|
|
async findStep(
|
|
scenarioId: string,
|
|
stepId: string,
|
|
): Promise<ScenarioStepEntity> {
|
|
const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId });
|
|
if (!step)
|
|
throw new NotFoundException(
|
|
`Step ${stepId} not found in scenario ${scenarioId}`,
|
|
);
|
|
return step;
|
|
}
|
|
|
|
async updateStep(
|
|
scenarioId: string,
|
|
stepId: string,
|
|
dto: UpdateScenarioStepDto,
|
|
): Promise<ScenarioStepEntity> {
|
|
const step = await this.findStep(scenarioId, stepId);
|
|
|
|
const nextOrder = dto.order;
|
|
Object.assign(step, {
|
|
...dto,
|
|
order: step.order,
|
|
title: dto.title ?? step.title,
|
|
execCode: dto.execCode ?? step.execCode,
|
|
});
|
|
await this.stepRepo.save(step);
|
|
|
|
if (nextOrder !== undefined) {
|
|
const ordered = await this.stepRepo.find({
|
|
where: { scenarioId },
|
|
order: { order: "ASC", createdAt: "ASC", id: "ASC" },
|
|
});
|
|
const currentIndex = ordered.findIndex((s) => s.id === stepId);
|
|
if (currentIndex >= 0) {
|
|
const boundedTarget = Math.max(
|
|
0,
|
|
Math.min(nextOrder, ordered.length - 1),
|
|
);
|
|
if (currentIndex !== boundedTarget) {
|
|
const [moved] = ordered.splice(currentIndex, 1);
|
|
ordered.splice(boundedTarget, 0, moved);
|
|
}
|
|
for (let i = 0; i < ordered.length; i += 1) {
|
|
const item = ordered[i];
|
|
if (item.order !== i) {
|
|
item.order = i;
|
|
await this.stepRepo.save(item);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
await this.normalizeStepOrder(scenarioId);
|
|
}
|
|
|
|
return this.findStep(scenarioId, stepId);
|
|
}
|
|
|
|
async removeStep(scenarioId: string, stepId: string): Promise<void> {
|
|
await this.findStep(scenarioId, stepId);
|
|
await this.stepRepo.delete(stepId);
|
|
await this.normalizeStepOrder(scenarioId);
|
|
}
|
|
|
|
// ── Scenario Credentials ──────────────────────────────────────────────────
|
|
|
|
async findScenarioCredentials(
|
|
scenarioId: string,
|
|
): Promise<ScenarioCredentialEntity[]> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
return this.scenarioCredRepo.find({
|
|
where: { scenarioId },
|
|
relations: ["credential"],
|
|
order: { alias: "ASC" },
|
|
});
|
|
}
|
|
|
|
async addScenarioCredential(
|
|
scenarioId: string,
|
|
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: string,
|
|
scCredId: string,
|
|
): 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: string,
|
|
): 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;
|
|
}
|
|
if (scs.length > 0) {
|
|
const ids = scs.map((sc) => sc.credentialId);
|
|
await this.credentialRepo
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ lastUsedAt: () => "CURRENT_TIMESTAMP" })
|
|
.whereInIds(ids)
|
|
.execute();
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ── Runs ──────────────────────────────────────────────────────────────────
|
|
|
|
async findRuns(
|
|
scenarioId: string,
|
|
query: RunsQueryDto,
|
|
): Promise<PaginatedResult<ScenarioRunEntity>> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const orderBy = query.orderBy ?? "createdAt";
|
|
const orderDir = query.orderDir ?? "DESC";
|
|
const where: Record<string, unknown> = { scenarioId };
|
|
if (query.status) where["status"] = query.status;
|
|
const [data, total] = await this.runRepo.findAndCount({
|
|
where,
|
|
relations: ["stepRuns"],
|
|
order: { [orderBy]: orderDir },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
});
|
|
return { data, total, page, limit };
|
|
}
|
|
|
|
async findAllRuns(
|
|
query: RunsQueryDto,
|
|
): Promise<
|
|
PaginatedResult<ScenarioRunEntity & { scenario: ScenarioEntity }>
|
|
> {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const orderBy = query.orderBy ?? "createdAt";
|
|
const orderDir = query.orderDir ?? "DESC";
|
|
const qb = this.runRepo
|
|
.createQueryBuilder("run")
|
|
.leftJoinAndSelect("run.scenario", "scenario");
|
|
if (query.status) qb.where("run.status = :status", { status: query.status });
|
|
if (orderBy === "scenario.name") {
|
|
qb.orderBy("scenario.name", orderDir);
|
|
} else {
|
|
qb.orderBy(`run.${orderBy}`, orderDir);
|
|
}
|
|
qb.skip((page - 1) * limit).take(limit);
|
|
const [data, total] = await qb.getManyAndCount();
|
|
return { data, total, page, limit } as PaginatedResult<
|
|
ScenarioRunEntity & { scenario: ScenarioEntity }
|
|
>;
|
|
}
|
|
|
|
async findRun(
|
|
scenarioId: string,
|
|
runId: string,
|
|
q?: string,
|
|
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
const run = await this.runRepo.findOne({
|
|
where: { id: runId, scenarioId },
|
|
relations: [
|
|
"stepRuns",
|
|
"stepRuns.scenarioStep",
|
|
"environment",
|
|
"session",
|
|
],
|
|
order: { stepRuns: { order: "ASC" } },
|
|
});
|
|
if (!run)
|
|
throw new NotFoundException(
|
|
`Run ${runId} not found in scenario ${scenarioId}`,
|
|
);
|
|
const logs = await this.runLogRepo.find({
|
|
where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
|
|
order: { seq: "ASC", createdAt: "ASC" },
|
|
});
|
|
return Object.assign(run, { logs });
|
|
}
|
|
|
|
async exportRunLogs(
|
|
scenarioId: string,
|
|
runId: string,
|
|
format: "csv" | "md",
|
|
): Promise<string> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
const run = await this.runRepo.findOne({
|
|
where: { id: runId, scenarioId },
|
|
});
|
|
if (!run)
|
|
throw new NotFoundException(
|
|
`Run ${runId} not found in scenario ${scenarioId}`,
|
|
);
|
|
const logs = await this.runLogRepo.find({
|
|
where: { runId },
|
|
order: { seq: "ASC", createdAt: "ASC" },
|
|
});
|
|
|
|
if (format === "csv") {
|
|
const escapeCsv = (value: string): string =>
|
|
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
const rows = [
|
|
["Timestamp", "Level", "Message"].map(escapeCsv).join(","),
|
|
...logs.map((l) =>
|
|
[l.createdAt.toISOString(), l.level, l.message]
|
|
.map((v) => escapeCsv(String(v)))
|
|
.join(","),
|
|
),
|
|
];
|
|
return rows.join("\n");
|
|
}
|
|
|
|
const sections = logs.map(
|
|
(l) => `## [${l.level}] ${l.createdAt.toISOString()}\n\n${l.message}`,
|
|
);
|
|
return [`# Run ${runId} logs`, ...sections].join("\n\n");
|
|
}
|
|
|
|
async waitForRun(
|
|
scenarioId: string,
|
|
runId: string,
|
|
timeoutMs = 300_000,
|
|
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
|
if (!run)
|
|
throw new NotFoundException(
|
|
`Run ${runId} not found in scenario ${scenarioId}`,
|
|
);
|
|
if (run.status === "pass" || run.status === "fail") {
|
|
return this.findRun(scenarioId, runId);
|
|
}
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
return this.findRun(scenarioId, runId);
|
|
}
|
|
|
|
async createRun(
|
|
scenarioId: string,
|
|
environmentId: string,
|
|
saveSession = false,
|
|
): Promise<ScenarioRunEntity> {
|
|
const scenario = await this.findOne(scenarioId);
|
|
const environment = await this.environmentRepo.findOneBy({
|
|
id: environmentId,
|
|
});
|
|
if (!environment) {
|
|
throw new NotFoundException(`Environment ${environmentId} not found`);
|
|
}
|
|
|
|
const run = await this.runRepo.save(
|
|
this.runRepo.create({
|
|
scenarioId,
|
|
environmentId,
|
|
status: "pending",
|
|
saveSession,
|
|
}),
|
|
);
|
|
|
|
const stepRuns = scenario.steps.map((step, index) =>
|
|
this.runStepRepo.create({
|
|
runId: run.id,
|
|
scenarioStepId: step.id,
|
|
order: step.order,
|
|
status: index === 0 ? "pending" : "waiting",
|
|
description: null,
|
|
}),
|
|
);
|
|
|
|
await this.runStepRepo.save(stepRuns);
|
|
|
|
return this.runRepo.findOne({
|
|
where: { id: run.id },
|
|
relations: ["stepRuns", "environment", "session"],
|
|
order: { stepRuns: { order: "ASC" } },
|
|
}) as Promise<ScenarioRunEntity>;
|
|
}
|
|
|
|
// ── Export / Import ───────────────────────────────────────────────────────
|
|
|
|
async exportScenario(
|
|
id: string,
|
|
options: { includeEnvironment: boolean; credentialIds: string[] },
|
|
): Promise<ExportEntity[]> {
|
|
const scenario = await this.findOne(id);
|
|
const entities: ExportEntity[] = [];
|
|
|
|
// Include environment entity if requested and linked
|
|
if (options.includeEnvironment && scenario.environment) {
|
|
const envDto: EnvironmentExportInlineDto = {
|
|
kind: "environment",
|
|
id: scenario.environment.id,
|
|
name: scenario.environment.name,
|
|
description: scenario.environment.description ?? undefined,
|
|
data: scenario.environment.data,
|
|
};
|
|
entities.push(envDto);
|
|
}
|
|
|
|
// Include selected credentials
|
|
if (options.credentialIds.length > 0) {
|
|
const creds = scenario.scenarioCredentials ?? [];
|
|
for (const sc of creds) {
|
|
if (
|
|
sc.credential &&
|
|
options.credentialIds.includes(sc.credential.id)
|
|
) {
|
|
const credDto: CredentialExportDto = {
|
|
kind: "credential",
|
|
id: sc.credential.id,
|
|
name: sc.credential.name,
|
|
data: sc.credential.data,
|
|
};
|
|
entities.push(credDto);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scenario is always last
|
|
const allCreds = scenario.scenarioCredentials ?? [];
|
|
const credentialMappings = allCreds
|
|
.filter(
|
|
(sc) =>
|
|
options.credentialIds.length === 0 ||
|
|
options.credentialIds.includes(sc.credentialId),
|
|
)
|
|
.map((sc) => ({ credentialId: sc.credentialId, alias: sc.alias }));
|
|
|
|
const scenarioDto: ScenarioExportDto = {
|
|
kind: "scenario",
|
|
id: scenario.id,
|
|
name: scenario.name,
|
|
description: scenario.description ?? undefined,
|
|
environmentId: scenario.environmentId ?? undefined,
|
|
credentials: credentialMappings.length > 0 ? credentialMappings : undefined,
|
|
steps: scenario.steps.map((s) => ({
|
|
title: s.title,
|
|
execCode: s.execCode,
|
|
})),
|
|
};
|
|
entities.push(scenarioDto);
|
|
|
|
return entities;
|
|
}
|
|
|
|
async importScenario(
|
|
payload: ScenarioExportDto | ExportEntity[],
|
|
): Promise<ScenarioEntity> {
|
|
// Accept plain {name, steps} objects (no kind) as well as the full export format
|
|
const raw = payload as unknown as Record<string, unknown>;
|
|
if (!Array.isArray(payload) && !raw["kind"]) {
|
|
if (typeof raw["name"] !== "string" || !raw["name"]) {
|
|
throw new BadRequestException("name is required");
|
|
}
|
|
if (!Array.isArray(raw["steps"])) {
|
|
throw new BadRequestException("steps must be an array");
|
|
}
|
|
(payload as unknown as Record<string, unknown>)["kind"] = "scenario";
|
|
}
|
|
|
|
const items: ExportEntity[] = Array.isArray(payload) ? payload : [payload];
|
|
|
|
let scenarioDto: ScenarioExportDto | undefined;
|
|
for (const item of items) {
|
|
if (item.kind === "environment") {
|
|
await this.environmentRepo
|
|
.findOneBy({ id: item.id })
|
|
.then(async (existing) => {
|
|
if (existing) {
|
|
Object.assign(existing, {
|
|
name: item.name,
|
|
description: (item as EnvironmentExportInlineDto).description ?? null,
|
|
data: (item as EnvironmentExportInlineDto).data,
|
|
});
|
|
return this.environmentRepo.save(existing);
|
|
}
|
|
return this.environmentRepo.save(
|
|
this.environmentRepo.create({
|
|
id: item.id,
|
|
name: item.name,
|
|
description: (item as EnvironmentExportInlineDto).description ?? null,
|
|
data: (item as EnvironmentExportInlineDto).data,
|
|
}),
|
|
);
|
|
});
|
|
} else if (item.kind === "credential") {
|
|
const credItem = item as CredentialExportDto;
|
|
const existingCred = credItem.id
|
|
? await this.credentialRepo.findOneBy({ id: credItem.id })
|
|
: null;
|
|
if (existingCred) {
|
|
existingCred.name = credItem.name;
|
|
existingCred.data = credItem.data ?? null;
|
|
await this.credentialRepo.save(existingCred);
|
|
} else {
|
|
await this.credentialRepo.save(
|
|
this.credentialRepo.create({
|
|
...(credItem.id ? { id: credItem.id } : {}),
|
|
name: credItem.name,
|
|
data: credItem.data ?? null,
|
|
}),
|
|
);
|
|
}
|
|
} else if (!item.kind || item.kind === "scenario") {
|
|
scenarioDto = item as ScenarioExportDto;
|
|
}
|
|
}
|
|
|
|
if (!scenarioDto) {
|
|
throw new BadRequestException("No scenario entity found in import payload");
|
|
}
|
|
if (typeof scenarioDto.name !== "string" || !scenarioDto.name) {
|
|
throw new BadRequestException("name is required");
|
|
}
|
|
if (!Array.isArray(scenarioDto.steps)) {
|
|
throw new BadRequestException("steps must be an array");
|
|
}
|
|
|
|
const dto = scenarioDto;
|
|
let scenario: ScenarioEntity;
|
|
if (dto.id) {
|
|
const existing = await this.scenarioRepo.findOneBy({ id: dto.id });
|
|
if (existing) {
|
|
existing.name = dto.name;
|
|
existing.description = dto.description ?? null;
|
|
existing.environmentId = dto.environmentId ?? existing.environmentId;
|
|
scenario = await this.scenarioRepo.save(existing);
|
|
await this.stepRepo.delete({ scenarioId: scenario.id });
|
|
} else {
|
|
scenario = await this.scenarioRepo.save(
|
|
this.scenarioRepo.create({
|
|
id: dto.id,
|
|
name: dto.name,
|
|
description: dto.description ?? null,
|
|
environmentId: dto.environmentId ?? null,
|
|
}),
|
|
);
|
|
}
|
|
} else {
|
|
scenario = await this.scenarioRepo.save(
|
|
this.scenarioRepo.create({
|
|
name: dto.name,
|
|
description: dto.description ?? null,
|
|
environmentId: dto.environmentId ?? null,
|
|
}),
|
|
);
|
|
}
|
|
if (dto.steps.length > 0) {
|
|
const steps = dto.steps.map((s, index) =>
|
|
this.stepRepo.create({
|
|
scenarioId: scenario.id,
|
|
order: index,
|
|
title: s.title ?? null,
|
|
execCode: s.execCode ?? null,
|
|
}),
|
|
);
|
|
await this.stepRepo.save(steps);
|
|
await this.normalizeStepOrder(scenario.id);
|
|
}
|
|
// Restore credential alias mappings
|
|
if (dto.credentials && dto.credentials.length > 0) {
|
|
await this.scenarioCredRepo.delete({ scenarioId: scenario.id });
|
|
const mappings = dto.credentials.map((c) =>
|
|
this.scenarioCredRepo.create({
|
|
scenarioId: scenario.id,
|
|
credentialId: c.credentialId,
|
|
alias: c.alias,
|
|
}),
|
|
);
|
|
await this.scenarioCredRepo.save(mappings);
|
|
}
|
|
return this.findOne(scenario.id);
|
|
}
|
|
|
|
// ── Files ─────────────────────────────────────────────────────────────────
|
|
|
|
async uploadFile(
|
|
scenarioId: string,
|
|
file: MulterFile,
|
|
expiresAtStr?: string,
|
|
): Promise<any> {
|
|
// Verify scenario exists
|
|
await this.findOne(scenarioId);
|
|
|
|
if (!file) {
|
|
throw new BadRequestException("No file provided");
|
|
}
|
|
|
|
// Parse optional expiresAt
|
|
let expiresAt: Date | undefined = undefined;
|
|
if (expiresAtStr) {
|
|
expiresAt = new Date(expiresAtStr);
|
|
if (isNaN(expiresAt.getTime())) {
|
|
throw new BadRequestException("Invalid expiresAt date");
|
|
}
|
|
}
|
|
|
|
// Save file to disk and database
|
|
const savedFile = await this.fileStorageService.saveFile(
|
|
file.buffer,
|
|
file.originalname,
|
|
file.mimetype,
|
|
expiresAt,
|
|
);
|
|
|
|
// Create scenario file mapping
|
|
const scenarioFile = this.scenarioFileRepo.create({
|
|
scenarioId,
|
|
fileId: savedFile.id,
|
|
});
|
|
await this.scenarioFileRepo.save(scenarioFile);
|
|
|
|
// Return file metadata
|
|
return {
|
|
id: savedFile.id,
|
|
name: savedFile.originalName,
|
|
mimeType: savedFile.mimeType,
|
|
size: savedFile.size,
|
|
expiresAt: savedFile.expiresAt,
|
|
createdAt: savedFile.createdAt,
|
|
};
|
|
}
|
|
|
|
async listScenarioFiles(
|
|
scenarioId: string,
|
|
limit?: number,
|
|
offset?: number,
|
|
): Promise<any> {
|
|
// Verify scenario exists
|
|
await this.findOne(scenarioId);
|
|
|
|
const result = await this.fileStorageService.listScenarioFiles(
|
|
scenarioId,
|
|
limit,
|
|
offset,
|
|
);
|
|
|
|
return {
|
|
items: result.items.map((item) => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
mimeType: item.mimeType,
|
|
size: item.size,
|
|
sha256: item.sha256,
|
|
expiresAt: item.expiresAt,
|
|
createdAt: item.createdAt,
|
|
})),
|
|
total: result.total,
|
|
};
|
|
}
|
|
|
|
async getScenarioFileContent(
|
|
scenarioId: string,
|
|
fileId: string,
|
|
res: Response,
|
|
): Promise<void> {
|
|
// Verify scenario exists
|
|
await this.findOne(scenarioId);
|
|
|
|
// Check if file is linked to this scenario
|
|
const link = await this.scenarioFileRepo.findOne({
|
|
where: { scenarioId, fileId },
|
|
relations: ["file"],
|
|
});
|
|
|
|
if (!link) {
|
|
throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`);
|
|
}
|
|
|
|
const file = link.file;
|
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
|
|
|
res.setHeader("Content-Type", file.mimeType);
|
|
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
|
res.setHeader("Content-Length", file.size);
|
|
|
|
res.sendFile(fullPath);
|
|
}
|
|
|
|
/**
|
|
* Get scenario file content as a buffer (for MCP use).
|
|
* Returns metadata plus raw bytes without streaming to Response.
|
|
*/
|
|
async getScenarioFileContentAsBuffer(
|
|
scenarioId: string,
|
|
fileId: string,
|
|
): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> {
|
|
// Verify scenario exists
|
|
await this.findOne(scenarioId);
|
|
|
|
// Check if file is linked to this scenario
|
|
const link = await this.scenarioFileRepo.findOne({
|
|
where: { scenarioId, fileId },
|
|
relations: ["file"],
|
|
});
|
|
|
|
if (!link) {
|
|
throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`);
|
|
}
|
|
|
|
const file = link.file;
|
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
|
|
|
// Read file content as buffer
|
|
const contentBuffer = await fs.readFile(fullPath);
|
|
|
|
return {
|
|
file: {
|
|
id: file.id,
|
|
name: file.originalName,
|
|
mimeType: file.mimeType,
|
|
size: file.size,
|
|
sha256: file.sha256,
|
|
expiresAt: file.expiresAt,
|
|
createdAt: file.createdAt,
|
|
},
|
|
contentBuffer,
|
|
};
|
|
}
|
|
|
|
async listRunFiles(
|
|
scenarioId: string,
|
|
runId: string,
|
|
limit?: number,
|
|
offset?: number,
|
|
): Promise<any> {
|
|
// Verify scenario and run exist
|
|
await this.findOne(scenarioId);
|
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
|
if (!run) {
|
|
throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`);
|
|
}
|
|
|
|
const result = await this.fileStorageService.listRunFiles(
|
|
runId,
|
|
limit,
|
|
offset,
|
|
);
|
|
|
|
return {
|
|
items: result.items.map((item) => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
mimeType: item.mimeType,
|
|
size: item.size,
|
|
sha256: item.sha256,
|
|
expiresAt: item.expiresAt,
|
|
createdAt: item.createdAt,
|
|
})),
|
|
total: result.total,
|
|
};
|
|
}
|
|
|
|
async getRunFileContent(
|
|
scenarioId: string,
|
|
runId: string,
|
|
fileId: string,
|
|
res: Response,
|
|
): Promise<void> {
|
|
// Verify scenario and run exist
|
|
await this.findOne(scenarioId);
|
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
|
if (!run) {
|
|
throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`);
|
|
}
|
|
|
|
// Check if file is linked to this run
|
|
const link = await this.scenarioRunFileRepo.findOne({
|
|
where: { runId, fileId },
|
|
relations: ["file"],
|
|
});
|
|
|
|
if (!link) {
|
|
throw new NotFoundException(`File ${fileId} not found in run ${runId}`);
|
|
}
|
|
|
|
const file = link.file;
|
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
|
|
|
res.setHeader("Content-Type", file.mimeType);
|
|
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
|
res.setHeader("Content-Length", file.size);
|
|
|
|
res.sendFile(fullPath);
|
|
}
|
|
|
|
/**
|
|
* Get run artifact file content as a buffer (for MCP use).
|
|
* Returns metadata plus raw bytes without streaming to Response.
|
|
*/
|
|
async getRunFileContentAsBuffer(
|
|
scenarioId: string,
|
|
runId: string,
|
|
fileId: string,
|
|
): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> {
|
|
// Verify scenario and run exist
|
|
await this.findOne(scenarioId);
|
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
|
if (!run) {
|
|
throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`);
|
|
}
|
|
|
|
// Check if file is linked to this run
|
|
const link = await this.scenarioRunFileRepo.findOne({
|
|
where: { runId, fileId },
|
|
relations: ["file"],
|
|
});
|
|
|
|
if (!link) {
|
|
throw new NotFoundException(`File ${fileId} not found in run ${runId}`);
|
|
}
|
|
|
|
const file = link.file;
|
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
|
|
|
// Read file content as buffer
|
|
const contentBuffer = await fs.readFile(fullPath);
|
|
|
|
return {
|
|
file: {
|
|
id: file.id,
|
|
name: file.originalName,
|
|
mimeType: file.mimeType,
|
|
size: file.size,
|
|
sha256: file.sha256,
|
|
expiresAt: file.expiresAt,
|
|
createdAt: file.createdAt,
|
|
},
|
|
contentBuffer,
|
|
};
|
|
}
|
|
}
|