/** * @file Pipeline templates for lanes. A template is a plain JSON list of nodes * (`server/data/pipelines/*.json` plus any override dropped in * `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto * a node through per-node `aliases`, and derives the five render states the * pipeline map draws. Pure functions — no DB, no I/O beyond the one-time * template load, so it stays trivially testable. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const DEFAULT_PIPELINE_ID = "default"; const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines"); /** Load every template once. A malformed file is skipped, never fatal. */ function loadAll() { const dirs = [BUILTIN_DIR]; if (process.env.DASHBOARD_PIPELINES_DIR) dirs.push(process.env.DASHBOARD_PIPELINES_DIR); const out = new Map(); for (const dir of dirs) { let files = []; try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { continue; // dir absent — fine } for (const f of files) { try { const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue; doc.nodes = doc.nodes.map((n) => ({ id: n.id, label: n.label || n.id, icon: n.icon || "", gate: !!n.gate, aliases: Array.isArray(n.aliases) ? n.aliases : [], detect: Array.isArray(n.detect) ? n.detect : [], })); out.set(doc.id, doc); // later dir wins — user override beats builtin } catch { /* skip malformed template */ } } } return out; } let cache = null; function templates() { if (!cache) cache = loadAll(); return cache; } /** Test/dev helper: forget the cached templates so a new file is picked up. */ function reload() { cache = null; } function listPipelines() { return [...templates().values()]; } /** Never throws: an unknown id yields the default template. */ function getPipeline(id) { const t = templates(); return t.get(id) || t.get(DEFAULT_PIPELINE_ID); } /** Index of the node matching `stage` by id or alias; -1 when unknown. */ function phaseIdx(pipeline, stage) { if (!stage) return -1; const s = String(stage).toLowerCase(); return pipeline.nodes.findIndex( (n) => n.id.toLowerCase() === s || n.aliases.some((a) => a.toLowerCase() === s) ); } /** * Index every recorded stage by the node it resolves to, so a stage declared * under an ALIAS keeps its record — and therefore its evidence. Keying the * records by node id alone (what `lane.stages` is keyed by, verbatim from the * declaration) silently drops `--evidence` the moment an agent says `e2e` * instead of `e2e-feature`, which is exactly what aliases exist to allow. * A record stored under the node's own id always wins over an alias record for * the same node; a key matching no node is skipped. */ function stageRecords(pipeline, stages) { const byIdx = new Map(); for (const [key, rec] of Object.entries(stages || {})) { const i = phaseIdx(pipeline, key); if (i === -1) continue; const isCanonical = key.toLowerCase() === pipeline.nodes[i].id.toLowerCase(); if (byIdx.has(i) && !isCanonical) continue; byIdx.set(i, rec); } return byIdx; } /** * Render state per node: * failed — the stage recorded result "fail" * current — the lane's current stage * done — recorded AND carries evidence (an artifact, not a claim) * passed-no-evidence — recorded without evidence, or implicitly skipped past * pending — not reached */ function nodeStates(pipeline, lane) { const records = stageRecords(pipeline, lane.stages); const cur = phaseIdx(pipeline, lane.stage); return pipeline.nodes.map((n, i) => { const rec = records.get(i); let state; if (rec && rec.result === "fail") state = "failed"; else if (i === cur) state = "current"; else if (rec) state = rec.evidence ? "done" : "passed-no-evidence"; else if (cur > -1 && i < cur) state = "passed-no-evidence"; else state = "pending"; return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, state }; }); } function progressPct(pipeline, lane) { const i = phaseIdx(pipeline, lane.stage); if (i < 0) return 0; return Math.round((i / (pipeline.nodes.length - 1)) * 100); } module.exports = { DEFAULT_PIPELINE_ID, listPipelines, getPipeline, phaseIdx, stageRecords, nodeStates, progressPct, reload, };