Files
liqa/server/src/scenario/scenario.service.ts
T
Andrii Arsenin 85a87b5fb2 fix(scenario-logs): fix out-of-order run log persistence
- 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
2026-09-15 13:17:29 +03:00

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,
};
}
}