import crypto from 'node:crypto'; import fs from 'node:fs'; import PDFDocument from 'pdfkit'; import { config } from '../config.js'; import { db, id, json, now } from '../db.js'; import { path, sanitizeFileName } from '../utils/pathUtils.js'; import { logger } from './logger.js'; const terminalStatuses = new Set(['completed', 'failed', 'canceled']); function stripAnsi(value = '') { return String(value).replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); } function artifactDir(jobId) { return path.join(config.fileLockerDir, 'job-output', sanitizeFileName(jobId, 'job')); } function checksumBuffer(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex'); } function safeArtifactName(job, suffix) { const script = sanitizeFileName(job.script_name || 'script', 'script'); return sanitizeFileName(`${job.id}-${script}-${suffix}`, `${job.id}-${suffix}`); } function getJobRecord(jobId) { return db.prepare(` SELECT j.*, r.name AS runplan_name, s.name AS script_name FROM jobs j LEFT JOIN runplans r ON r.id = j.runplan_id LEFT JOIN scripts s ON s.id = j.script_id WHERE j.id = ? `).get(jobId); } function getHostRows(jobId) { return db.prepare(` SELECT jh.*, h.name AS host_name, h.address AS host_address FROM job_hosts jh LEFT JOIN hosts h ON h.id = jh.host_id WHERE jh.job_id = ? ORDER BY COALESCE(h.name, jh.host_id, jh.id) `).all(jobId); } function getLogs(jobId) { return db.prepare(` SELECT jl.*, h.name AS host_name FROM job_logs jl LEFT JOIN hosts h ON h.id = jl.host_id WHERE jl.job_id = ? ORDER BY jl.created_at, jl.id `).all(jobId); } function stdoutByHost(hosts, logs) { const buckets = new Map(hosts.map((host) => [host.host_id || host.id, { hostId: host.host_id || '', hostName: host.host_name || host.host_id || 'Unassigned host', hostAddress: host.host_address || '', status: host.status, exitCode: host.exit_code, lines: [] }])); for (const log of logs.filter((entry) => entry.stream === 'stdout')) { const key = log.host_id || 'job'; if (!buckets.has(key)) { buckets.set(key, { hostId: log.host_id || '', hostName: log.host_name || 'Job', hostAddress: '', status: '', exitCode: null, lines: [] }); } buckets.get(key).lines.push(stripAnsi(log.line)); } return [...buckets.values()]; } function terminalTranscript(logs) { return logs.map((log) => { const host = log.host_name || log.host_id || 'system'; return `[${log.created_at}] [${String(log.stream || '').toUpperCase()}] [${host}] ${stripAnsi(log.line)}`; }).join('\n') + '\n'; } function jsonRows(lines) { const text = lines.join('\n').trim(); if (!text) return null; try { const parsed = JSON.parse(text); const rows = Array.isArray(parsed) ? parsed : [parsed]; if (rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) return { rows, parser: 'json' }; } catch { // Try line-delimited JSON next. } const parsedLines = []; for (const line of lines.map((item) => item.trim()).filter(Boolean)) { try { const parsed = JSON.parse(line); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; parsedLines.push(parsed); } catch { return null; } } return parsedLines.length ? { rows: parsedLines, parser: 'json-lines' } : null; } function splitDelimited(line, delimiter) { if (delimiter === '\t') return line.split('\t').map((part) => part.trim()); return line.split(delimiter).map((part) => part.trim()); } function delimitedRows(lines) { const clean = lines.map((line) => line.trim()).filter(Boolean); if (clean.length < 2) return null; const delimiter = ['\t', ',', '|'].find((candidate) => splitDelimited(clean[0], candidate).length > 1); if (!delimiter) return null; const headers = splitDelimited(clean[0], delimiter).filter(Boolean); if (headers.length < 2) return null; const rows = clean.slice(1).map((line) => { const values = splitDelimited(line, delimiter); if (values.length < 2) return null; return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])); }).filter(Boolean); return rows.length ? { rows, parser: delimiter === '\t' ? 'tsv' : delimiter === ',' ? 'csv' : 'pipe-delimited' } : null; } function powershellTableRows(lines) { const clean = lines.map((line) => line.replace(/\s+$/g, '')).filter((line) => line.trim()); if (clean.length < 3) return null; const separatorIndex = clean.findIndex((line, index) => index > 0 && /^[-\s]+$/.test(line) && line.trim().includes('-')); if (separatorIndex < 1) return null; const headers = clean[separatorIndex - 1].trim().split(/\s{2,}/).filter(Boolean); if (headers.length < 2) return null; const rows = clean.slice(separatorIndex + 1).map((line) => { const values = line.trim().split(/\s{2,}/); if (!values.length) return null; return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])); }).filter(Boolean); return rows.length ? { rows, parser: 'powershell-table' } : null; } function powershellObjectRows(lines) { const clean = lines.map((line) => line.trim()).filter(Boolean); if (!clean.length || !clean.every((line) => /^[^:]+:\s*.*$/.test(line))) return null; const row = {}; for (const line of clean) { const index = line.indexOf(':'); const key = line.slice(0, index).trim(); if (!key) return null; row[key] = line.slice(index + 1).trim(); } return Object.keys(row).length > 1 ? { rows: [row], parser: 'powershell-object' } : null; } function normalizeCellValue(value) { if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); return String(value); } function parseHostRows(host) { const parsed = jsonRows(host.lines) || delimitedRows(host.lines) || powershellTableRows(host.lines) || powershellObjectRows(host.lines); if (parsed) { return { parser: parsed.parser, rows: parsed.rows.map((row) => ({ Host: host.hostName, HostStatus: host.status || '', ...Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCellValue(value)])) })) }; } const lastLine = [...host.lines].reverse().find((line) => line.trim()) || ''; return { parser: 'stdout-last-line', rows: [{ Host: host.hostName, HostStatus: host.status || '', Output: lastLine }] }; } function tablePayload(job, hosts) { const parsedHosts = hosts.map(parseHostRows); const rows = parsedHosts.flatMap((entry) => entry.rows).map((row, index) => ({ id: `row-${index + 1}`, ...row })); const keys = [...new Set(rows.flatMap((row) => Object.keys(row).filter((key) => key !== 'id')))]; const preferred = ['Host', 'HostStatus', 'Output']; const ordered = [...preferred.filter((key) => keys.includes(key)), ...keys.filter((key) => !preferred.includes(key))]; return { jobId: job.id, runplanName: job.runplan_name || '', scriptName: job.script_name || '', generatedAt: now(), parsers: [...new Set(parsedHosts.map((entry) => entry.parser))], columns: ordered.map((key) => ({ key, label: key })), rows }; } async function pdfBuffer(job, hosts) { return new Promise((resolve, reject) => { const chunks = []; const doc = new PDFDocument({ autoFirstPage: false, margins: { top: 54, bottom: 48, left: 48, right: 48 } }); doc.on('data', (chunk) => chunks.push(chunk)); doc.on('error', reject); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.addPage(); doc.font('Helvetica-Bold').fontSize(18).fillColor('#101827').text('POSHManager Job STDOUT Report'); doc.moveDown(0.35); doc.font('Helvetica').fontSize(9).fillColor('#45556c') .text(`Job: ${job.id}`) .text(`Script: ${job.script_name || 'Unknown script'}`) .text(`RunPlan: ${job.runplan_name || 'Script test / ad hoc run'}`) .text(`Status: ${job.status}`) .text(`Generated: ${new Date().toISOString()}`); doc.moveDown(1); for (const host of hosts) { if (doc.y > 680) doc.addPage(); doc.font('Helvetica-Bold').fontSize(12).fillColor('#101827').text(host.hostName || 'Host'); doc.font('Helvetica').fontSize(8).fillColor('#64748b') .text(`Address: ${host.hostAddress || '-'} Status: ${host.status || '-'} Exit code: ${host.exitCode ?? '-'}`); doc.moveDown(0.4); const lines = host.lines.length ? host.lines : ['(No STDOUT captured for this host.)']; doc.font('Courier').fontSize(8).fillColor('#111827'); for (const line of lines) { if (doc.y > 730) { doc.addPage(); doc.font('Courier').fontSize(8).fillColor('#111827'); } doc.text(line || ' ', { width: 500 }); } doc.moveDown(0.9); } doc.end(); }); } function writeArtifact(job, kind, name, mimeType, content, metadata) { const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8'); const dir = artifactDir(job.id); fs.mkdirSync(dir, { recursive: true }); const storagePath = path.join(dir, sanitizeFileName(name)); fs.writeFileSync(storagePath, buffer); const checksum = checksumBuffer(buffer); const timestamp = now(); const outputId = db.prepare('SELECT id FROM job_outputs WHERE job_id = ? AND kind = ?').get(job.id, kind)?.id || id('jout'); db.prepare(` INSERT INTO job_outputs (id, job_id, kind, name, mime_type, storage_path, size_bytes, checksum, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(job_id, kind) DO UPDATE SET name = excluded.name, mime_type = excluded.mime_type, storage_path = excluded.storage_path, size_bytes = excluded.size_bytes, checksum = excluded.checksum, metadata_json = excluded.metadata_json, updated_at = excluded.updated_at `).run(outputId, job.id, kind, name, mimeType, storagePath, buffer.length, checksum, json(metadata, {}), timestamp, timestamp); } export async function generateJobOutputs(jobId) { const job = getJobRecord(jobId); if (!job || !terminalStatuses.has(job.status)) return []; const hosts = stdoutByHost(getHostRows(jobId), getLogs(jobId)); const logs = getLogs(jobId); const table = tablePayload(job, hosts); const pdf = await pdfBuffer(job, hosts); const stdoutLineCount = hosts.reduce((sum, host) => sum + host.lines.length, 0); const metadata = { hostCount: hosts.length, stdoutLineCount, generatedAt: now() }; writeArtifact(job, 'stdout-pdf', safeArtifactName(job, 'stdout.pdf'), 'application/pdf', pdf, metadata); writeArtifact(job, 'datatable', safeArtifactName(job, 'datatable.json'), 'application/json', JSON.stringify(table, null, 2), { ...metadata, rowCount: table.rows.length, columns: table.columns.map((column) => column.key), parsers: table.parsers }); writeArtifact(job, 'terminal', safeArtifactName(job, 'terminal.log'), 'text/plain', terminalTranscript(logs), { ...metadata, logLineCount: logs.length }); return db.prepare('SELECT * FROM job_outputs WHERE job_id = ? ORDER BY kind').all(jobId); } export async function generateJobOutputsSafe(jobId) { try { return await generateJobOutputs(jobId); } catch (error) { logger.error('failed to generate job outputs', { jobId, error }); return []; } }