import { config } from '../config.js'; import { db, parseJson } from '../db.js'; import { camelJobOutput } from './mappers.js'; import { isPathInside, path } from '../utils/pathUtils.js'; // Keep output artifact visibility identical to job visibility so logs and // generated files do not leak across users. function visibleJobClause() { return `( j.triggered_by = ? OR r.visibility = 'shared' OR r.owner_user_id = ? OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) )`; } function outputSelect() { return ` SELECT jo.*, j.status AS job_status, j.runplan_id, j.script_id, j.triggered_by, r.name AS runplan_name, s.name AS script_name, COALESCE(hs.host_total_count, 0) AS host_total_count, COALESCE(hs.host_success_count, 0) AS host_success_count, COALESCE(hs.host_failed_count, 0) AS host_failed_count, COALESCE(hs.host_skipped_count, 0) AS host_skipped_count, COALESCE(hs.host_canceled_count, 0) AS host_canceled_count FROM job_outputs jo JOIN jobs j ON j.id = jo.job_id LEFT JOIN runplans r ON r.id = j.runplan_id LEFT JOIN scripts s ON s.id = j.script_id LEFT JOIN ( SELECT job_id, COUNT(*) AS host_total_count, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS host_success_count, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS host_failed_count, SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS host_skipped_count, SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) AS host_canceled_count FROM job_hosts GROUP BY job_id ) hs ON hs.job_id = j.id `; } export function listJobOutputs(userId, isAdmin = false) { const rows = isAdmin ? db.prepare(` ${outputSelect()} ORDER BY jo.updated_at DESC LIMIT 250 `).all() : db.prepare(` ${outputSelect()} WHERE ${visibleJobClause()} ORDER BY jo.updated_at DESC LIMIT 250 `).all(userId, userId, userId); return rows.map(camelJobOutput); } export function listJobOutputsForJob(jobId, userId, isAdmin = false) { const rows = isAdmin ? db.prepare(` ${outputSelect()} WHERE jo.job_id = ? ORDER BY jo.kind `).all(jobId) : db.prepare(` ${outputSelect()} WHERE jo.job_id = ? AND ${visibleJobClause()} ORDER BY jo.kind `).all(jobId, userId, userId, userId); return rows.map(camelJobOutput); } export function getJobOutput(outputId, userId, isAdmin = false) { const row = isAdmin ? db.prepare(`${outputSelect()} WHERE jo.id = ?`).get(outputId) : db.prepare(`${outputSelect()} WHERE jo.id = ? AND ${visibleJobClause()}`).get(outputId, userId, userId, userId); return row ? camelJobOutput(row) : null; } export function getJobOutputFile(outputId, userId, isAdmin = false) { const row = isAdmin ? db.prepare(`${outputSelect()} WHERE jo.id = ?`).get(outputId) : db.prepare(`${outputSelect()} WHERE jo.id = ? AND ${visibleJobClause()}`).get(outputId, userId, userId, userId); if (!row) return null; const filePath = path.resolve(row.storage_path); if (!isPathInside(config.fileLockerDir, filePath)) { throw new Error('Job output path is outside the configured FileLocker directory'); } return { output: camelJobOutput(row), filePath, metadata: parseJson(row.metadata_json, {}) }; }