/** 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)}
` +
`${body} `,
);
}
return `
${escapeHtml(`${model.workflow} — artifact viewer`)}
Artifact viewer
File Bytes ${rows.join("")}
${sections.join("\n")}
`;
}