/** Static artifact viewer generated next to workflow run report.html. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { RunReportModel } from "./html.js"; import { artifactViewerAnchor, escapeHtml, safeRelativeHref } from "./safe-html.js"; export const ARTIFACT_VIEWER_FILE = "artifact-viewer.html"; const ARTIFACT_PREVIEW_BYTES = 160_000; async function readPreview( file: string, maxBytes: number, ): Promise<{ text: string; truncated: boolean; empty: boolean }> { try { const stat = await fs.stat(file); if (!stat.isFile()) return { text: "", truncated: false, empty: true }; const handle = await fs.open(file, "r"); try { const buffer = Buffer.alloc(Math.min(maxBytes, stat.size)); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); return { text: buffer.subarray(0, bytesRead).toString("utf8"), truncated: stat.size > bytesRead, empty: stat.size === 0, }; } finally { await handle.close(); } } catch { return { text: "", truncated: false, empty: true }; } } /** Una línea no vacía "parece" JSONL si, recortada, arranca con { o [. */ function looksLikeJsonLine(line: string): boolean { const trimmed = line.trim(); return trimmed.length > 0 && (trimmed[0] === "{" || trimmed[0] === "["); } /** * Si `text` es JSONL (cada línea no vacía parsea como JSON y arranca con { o [), re-emite * cada línea con JSON.stringify(..., null, 2), separadas por una línea en blanco — legible * en el
 estático del viewer en vez de una sola línea gigante por evento (típico de
 * `agents/*.stdout.log`, transcripciones de sesión). Cualquier archivo que no sea JSONL
 * uniforme pasa sin tocarse.
 *
 * Cuando el preview vino truncado a un límite de bytes (`opts.truncated`), la última línea
 * puede estar cortada a mitad de objeto; en ese caso se descarta esa línea parcial en vez
 * de abortar el formateo de las líneas completas, y se deja una nota al final.
 */
export function formatArtifactPreviewText(text: string, opts: { truncated?: boolean } = {}): string {
	const nonBlank = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
	if (nonBlank.length === 0) return text;

	const droppedPartialTail = opts.truncated && nonBlank.length > 1;
	const candidates = droppedPartialTail ? nonBlank.slice(0, -1) : nonBlank;
	if (candidates.length === 0 || !candidates.every(looksLikeJsonLine)) return text;

	const pretty: string[] = [];
	for (const line of candidates) {
		try {
			pretty.push(JSON.stringify(JSON.parse(line), null, 2));
		} catch {
			return text; // cualquier línea completa que no parsea: conservador, no tocar nada
		}
	}
	const body = pretty.join("\n\n");
	return droppedPartialTail
		? `${body}\n\n… (última línea truncada por el límite de bytes del preview, omitida)`
		: body;
}

function containedFile(runDir: string, rel: string): string | undefined {
	if (!safeRelativeHref(rel)) return undefined;
	const root = path.resolve(runDir);
	const file = path.resolve(root, rel);
	if (file !== root && file.startsWith(root + path.sep)) return file;
	return undefined;
}

export async function buildRunArtifactViewerHtml(model: RunReportModel, runDir: string): Promise {
	const rows: string[] = [];
	const sections: string[] = [];
	for (const artifact of model.artifacts) {
		const anchor = artifactViewerAnchor(artifact.path);
		const rawHref = safeRelativeHref(artifact.path);
		if (!anchor || !rawHref) continue;
		const file = containedFile(runDir, artifact.path);
		const preview = file ? await readPreview(file, ARTIFACT_PREVIEW_BYTES) : undefined;
		const size = artifact.bytes === undefined ? "" : `${artifact.bytes} bytes`;
		rows.push(
			`${escapeHtml(artifact.path)}${escapeHtml(size)}`,
		);
		const body = preview?.empty
			? `
Empty file.
` : `
${escapeHtml(formatArtifactPreviewText(preview?.text ?? "Unable to read file.", { truncated: preview?.truncated }))}
`; sections.push( `

${escapeHtml(artifact.path)}

` + `
${escapeHtml(size || "size unknown")}` + (preview?.truncated ? ` · preview truncated at ${ARTIFACT_PREVIEW_BYTES} bytes` : "") + ` · raw file
${body}
`, ); } return ` ${escapeHtml(`${model.workflow} — artifact viewer`)}

Artifact viewer

Static previews for ${escapeHtml(model.runId)}. Large files are bounded so this page stays responsive; use “raw file” for the original.

${rows.join("")}
FileBytes
${sections.join("\n")}
`; }