import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { defineTool } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { HarnessError } from "../errors/harness-error.js"; import { SafeWorkspace } from "../workspace/safe-workspace.js"; const exec = promisify(execFile); const formats = { json: "application/json", html: "text/html; charset=utf-8", pdf: "application/pdf" } as const; type Format = keyof typeof formats; type QueryRecord = { queryId: string; requestedAt: string; arguments: Record; result: unknown }; type ReportInput = { data: Record; source_query_ids: string[] }; export type MinisReportOptions = { workspace: SafeWorkspace; sessionId: string; skillRoot: string; pythonExecutable?: string; chromeExecutable?: string }; export class MinisReportService { readonly #queries = new Map(); readonly #reports = new Map }>(); public constructor(private readonly options: MinisReportOptions) {} public recordQuery(args: Record, result: unknown): string { const queryId = `query_${randomUUID()}`; this.#queries.set(queryId, { queryId, requestedAt: new Date().toISOString(), arguments: structuredClone(args), result: structuredClone(result) }); return queryId; } public async generate(input: ReportInput, signal?: AbortSignal) { if (!input.source_query_ids.length || input.source_query_ids.some((id) => !this.#queries.has(id))) { throw new HarnessError("REPORT_SOURCE_REQUIRED", "Report requires successful swads query IDs from this Session; never use example data"); } const { data } = input; for (const key of ["title", "report_type", "period", "comparison_period", "currency", "timezone", "data_status", "conclusion"]) { if (typeof data[key] !== "string" || !data[key].trim()) throw new HarnessError("INVALID_REPORT", `Report requires ${key}`); } if (!["daily", "weekly"].includes(String(data.report_type)) || !Array.isArray(data.cards) || !Array.isArray(data.issues)) throw new HarnessError("INVALID_REPORT", "Invalid report type, cards or issues"); if (data.currency !== "USD") throw new HarnessError("REPORT_CURRENCY_UNSUPPORTED", "This renderer labels charts in USD. Keep other currencies separate and do not generate misleading USD charts."); const payload = JSON.stringify({ ...data, source_queries: input.source_query_ids.map((id) => this.#queries.get(id)) }, null, 2); if (Buffer.byteLength(payload) > 4_194_304) throw new HarnessError("REPORT_TOO_LARGE", "Report source data exceeds 4 MiB"); const reportId = `rpt_${randomUUID()}`; // A fresh random directory avoids overwrites and untrusted symlink targets. const root = await mkdtemp(join(this.options.workspace.root, ".report-")); const jsonPath = join(root, "report.json"); const htmlPath = join(root, "report.html"); await writeFile(jsonPath, payload, { mode: 0o600, flag: "wx" }); const source = await SafeWorkspace.create(this.options.skillRoot); const script = await source.resolveReadable("scripts/generate_report.py"); const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "en_US.UTF-8", PYTHONDONTWRITEBYTECODE: "1", PYTHONNOUSERSITE: "1" }; try { await exec(this.options.pythonExecutable ?? "python3", ["-I", script, jsonPath, htmlPath], { cwd: root, env, timeout: 30_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }), }); await chmod(htmlPath, 0o600); } catch { throw new HarnessError("REPORT_RENDER_FAILED", "HTML generation failed. Check report JSON against the Skill schema and the configured Python runtime."); } const available = new Set(["json", "html"]); let pdfWarning: string | undefined; const pdfPath = join(root, "report.pdf"); const profile = await mkdtemp(join(root, ".chrome-")); try { await exec(this.options.chromeExecutable ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "chromium"), [ "--headless", "--disable-gpu", "--disable-extensions", "--disable-background-networking", "--no-first-run", "--host-resolver-rules=MAP * ~NOTFOUND", "--no-pdf-header-footer", `--user-data-dir=${profile}`, `--print-to-pdf=${pdfPath}`, pathToFileURL(htmlPath).href, ], { cwd: root, env, timeout: 60_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }) }); const pdf = await readFile(pdfPath); if (!pdf.subarray(0, 5).equals(Buffer.from("%PDF-")) || pdf.byteLength < 1_000 || !pdf.includes(Buffer.from("%%EOF"))) throw new Error("Invalid PDF output"); await chmod(pdfPath, 0o600); available.add("pdf"); } catch { pdfWarning = "PDF 生成失败:请检查 Chrome/Chromium 安装与运行权限;HTML 和 JSON 已保留。"; } finally { await rm(profile, { recursive: true, force: true }).catch(() => undefined); } if (signal?.aborted) throw new HarnessError("REPORT_CANCELLED", "Report generation was cancelled"); this.#reports.set(reportId, { root, formats: available }); return { reportId, title: data.title, pdfStatus: available.has("pdf") ? "ready" : "failed", ...(pdfWarning === undefined ? {} : { warning: pdfWarning }), files: await Promise.all([...available].map(async (format) => ({ format, url: `/api/sessions/${this.options.sessionId}/reports/${reportId}/${format}`, byteSize: (await stat(join(root, `report.${format}`))).size, }))), }; } public async read(reportId: string, format: string): Promise<{ bytes: Buffer; contentType: string }> { const report = this.#reports.get(reportId); if (report === undefined || !Object.hasOwn(formats, format) || !report.formats.has(format as Format)) throw new HarnessError("REPORT_NOT_FOUND", "Report file not found"); const file = await this.options.workspace.resolveReadable(join(report.root, `report.${format}`)); return { bytes: await readFile(file), contentType: formats[format as Format] }; } } export function createMinisReportTool(service: MinisReportService) { return defineTool({ name: "generate_minis_report", label: "Drama Minis HTML / PDF", description: "Generate HTML + A4 PDF + editable JSON using the installed Drama Minis Skill renderer. Read the Skill, references and example JSON structure first, NEVER reuse example values. Pass source_query_ids returned by successful REAL metrics_semantic_query calls in this Session, and freshly normalized report data (title, report_type daily/weekly, period, comparison_period, currency USD, timezone, data_status, conclusion, cards, issues, funnel, countries, country_revenue, country_funnels; weekly trend). Preserve missing values and source/account coverage in footnote. Requires page approval. Only declare PDF success when pdfStatus is ready. This replaces shell execution for the Skill report script.", parameters: Type.Object({ source_query_ids: Type.Array(Type.String(), { minItems: 1 }), data: Type.Record(Type.String(), Type.Unknown()) }, { additionalProperties: false }), executionMode: "sequential", async execute(_id, params, signal) { const details = await service.generate(params, signal); return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details }; }, }); }